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.html22
-rw-r--r--client/src/app/+about/about-instance/about-instance.component.scss7
-rw-r--r--client/src/app/+about/about-instance/about-instance.component.ts19
-rw-r--r--client/src/app/+accounts/account-about/account-about.component.ts4
-rw-r--r--client/src/app/+accounts/account-videos/account-videos.component.ts23
-rw-r--r--client/src/app/+accounts/accounts-routing.module.ts4
-rw-r--r--client/src/app/+accounts/accounts.component.ts1
-rw-r--r--client/src/app/+admin/admin-routing.module.ts4
-rw-r--r--client/src/app/+admin/admin.component.html8
-rw-r--r--client/src/app/+admin/admin.component.scss0
-rw-r--r--client/src/app/+admin/admin.component.ts15
-rw-r--r--client/src/app/+admin/admin.module.ts23
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html69
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts16
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.html20
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.scss8
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.ts64
-rw-r--r--client/src/app/+admin/follows/shared/follow.service.ts30
-rw-r--r--client/src/app/+admin/jobs/index.ts4
-rw-r--r--client/src/app/+admin/jobs/job.component.ts6
-rw-r--r--client/src/app/+admin/jobs/job.routes.ts32
-rw-r--r--client/src/app/+admin/jobs/jobs-list/index.ts1
-rw-r--r--client/src/app/+admin/jobs/shared/index.ts1
-rw-r--r--client/src/app/+admin/moderation/index.ts1
-rw-r--r--client/src/app/+admin/moderation/moderation.component.html4
-rw-r--r--client/src/app/+admin/moderation/moderation.component.ts11
-rw-r--r--client/src/app/+admin/moderation/moderation.routes.ts17
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html4
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts23
-rw-r--r--client/src/app/+admin/moderation/video-auto-blacklist-list/index.ts1
-rw-r--r--client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.html19
-rw-r--r--client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.scss14
-rw-r--r--client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.ts86
-rw-r--r--client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.html2
-rw-r--r--client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts24
-rw-r--r--client/src/app/+admin/system/debug/debug.component.html19
-rw-r--r--client/src/app/+admin/system/debug/debug.component.scss6
-rw-r--r--client/src/app/+admin/system/debug/debug.component.ts31
-rw-r--r--client/src/app/+admin/system/debug/debug.service.ts25
-rw-r--r--client/src/app/+admin/system/debug/index.ts2
-rw-r--r--client/src/app/+admin/system/index.ts4
-rw-r--r--client/src/app/+admin/system/jobs/index.ts2
-rw-r--r--client/src/app/+admin/system/jobs/job.service.ts (renamed from client/src/app/+admin/jobs/shared/job.service.ts)0
-rw-r--r--client/src/app/+admin/system/jobs/jobs.component.html (renamed from client/src/app/+admin/jobs/jobs-list/jobs-list.component.html)0
-rw-r--r--client/src/app/+admin/system/jobs/jobs.component.scss (renamed from client/src/app/+admin/jobs/jobs-list/jobs-list.component.scss)0
-rw-r--r--client/src/app/+admin/system/jobs/jobs.component.ts (renamed from client/src/app/+admin/jobs/jobs-list/jobs-list.component.ts)14
-rw-r--r--client/src/app/+admin/system/logs/index.ts2
-rw-r--r--client/src/app/+admin/system/logs/log-row.model.ts21
-rw-r--r--client/src/app/+admin/system/logs/logs.component.html31
-rw-r--r--client/src/app/+admin/system/logs/logs.component.scss49
-rw-r--r--client/src/app/+admin/system/logs/logs.component.ts111
-rw-r--r--client/src/app/+admin/system/logs/logs.service.ts33
-rw-r--r--client/src/app/+admin/system/system.component.html13
-rw-r--r--client/src/app/+admin/system/system.component.scss4
-rw-r--r--client/src/app/+admin/system/system.component.ts24
-rw-r--r--client/src/app/+admin/system/system.routes.ts56
-rw-r--r--client/src/app/+admin/users/user-edit/index.ts1
-rw-r--r--client/src/app/+admin/users/user-edit/user-create.component.ts7
-rw-r--r--client/src/app/+admin/users/user-edit/user-edit.component.html21
-rw-r--r--client/src/app/+admin/users/user-edit/user-edit.component.scss22
-rw-r--r--client/src/app/+admin/users/user-edit/user-edit.ts16
-rw-r--r--client/src/app/+admin/users/user-edit/user-password.component.html21
-rw-r--r--client/src/app/+admin/users/user-edit/user-password.component.scss22
-rw-r--r--client/src/app/+admin/users/user-edit/user-password.component.ts64
-rw-r--r--client/src/app/+admin/users/user-edit/user-update.component.ts22
-rw-r--r--client/src/app/+my-account/my-account-history/my-account-history.component.html23
-rw-r--r--client/src/app/+my-account/my-account-history/my-account-history.component.scss69
-rw-r--r--client/src/app/+my-account/my-account-history/my-account-history.component.ts9
-rw-r--r--client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html13
-rw-r--r--client/src/app/+my-account/my-account-routing.module.ts57
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html8
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss10
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts11
-rw-r--r--client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts4
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html2
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts4
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts93
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html69
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss27
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts13
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html26
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss39
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts153
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts136
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html21
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss51
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts88
-rw-r--r--client/src/app/+my-account/my-account-videos/my-account-videos.component.html78
-rw-r--r--client/src/app/+my-account/my-account-videos/my-account-videos.component.scss120
-rw-r--r--client/src/app/+my-account/my-account-videos/my-account-videos.component.ts127
-rw-r--r--client/src/app/+my-account/my-account.component.ts29
-rw-r--r--client/src/app/+my-account/my-account.module.ts21
-rw-r--r--client/src/app/+my-account/shared/actor-avatar-info.component.scss6
-rw-r--r--client/src/app/+video-channels/video-channel-about/video-channel-about.component.ts6
-rw-r--r--client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.html11
-rw-r--r--client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.scss9
-rw-r--r--client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.ts63
-rw-r--r--client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts23
-rw-r--r--client/src/app/+video-channels/video-channels-routing.module.ts14
-rw-r--r--client/src/app/+video-channels/video-channels.component.html1
-rw-r--r--client/src/app/+video-channels/video-channels.module.ts4
-rw-r--r--client/src/app/app-routing.module.ts9
-rw-r--r--client/src/app/app.component.scss5
-rw-r--r--client/src/app/app.component.ts132
-rw-r--r--client/src/app/core/auth/auth.service.ts2
-rw-r--r--client/src/app/core/core.module.ts4
-rw-r--r--client/src/app/core/hotkeys/hotkeys.component.scss9
-rw-r--r--client/src/app/core/notification/user-notification-socket.service.ts29
-rw-r--r--client/src/app/core/routing/custom-reuse-strategy.ts81
-rw-r--r--client/src/app/core/routing/disable-for-reuse-hook.ts7
-rw-r--r--client/src/app/core/routing/server-config-resolver.service.ts17
-rw-r--r--client/src/app/core/server/server.service.ts47
-rw-r--r--client/src/app/login/login-routing.module.ts6
-rw-r--r--client/src/app/menu/avatar-notification.component.html29
-rw-r--r--client/src/app/menu/avatar-notification.component.scss20
-rw-r--r--client/src/app/menu/avatar-notification.component.ts41
-rw-r--r--client/src/app/menu/menu.component.html60
-rw-r--r--client/src/app/menu/menu.component.scss103
-rw-r--r--client/src/app/search/advanced-search.model.ts14
-rw-r--r--client/src/app/search/search-filters.component.html23
-rw-r--r--client/src/app/search/search-filters.component.ts42
-rw-r--r--client/src/app/search/search.component.html11
-rw-r--r--client/src/app/search/search.component.scss49
-rw-r--r--client/src/app/search/search.component.ts17
-rw-r--r--client/src/app/shared/angular/from-now.pipe.ts (renamed from client/src/app/shared/misc/from-now.pipe.ts)2
-rw-r--r--client/src/app/shared/angular/number-formatter.pipe.ts (renamed from client/src/app/shared/misc/number-formatter.pipe.ts)0
-rw-r--r--client/src/app/shared/angular/object-length.pipe.ts (renamed from client/src/app/shared/misc/object-length.pipe.ts)0
-rw-r--r--client/src/app/shared/angular/peertube-template.directive.ts12
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.html21
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.scss25
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.ts25
-rw-r--r--client/src/app/shared/buttons/button.component.ts2
-rw-r--r--client/src/app/shared/forms/form-reactive.ts2
-rw-r--r--client/src/app/shared/forms/form-validators/index.ts1
-rw-r--r--client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts66
-rw-r--r--client/src/app/shared/forms/form-validators/video-validators.service.ts6
-rw-r--r--client/src/app/shared/forms/markdown-textarea.component.ts6
-rw-r--r--client/src/app/shared/forms/peertube-checkbox.component.scss2
-rw-r--r--client/src/app/shared/forms/peertube-checkbox.component.ts11
-rw-r--r--client/src/app/shared/forms/timestamp-input.component.html4
-rw-r--r--client/src/app/shared/forms/timestamp-input.component.scss8
-rw-r--r--client/src/app/shared/forms/timestamp-input.component.ts61
-rw-r--r--client/src/app/shared/icons/global-icon.component.html0
-rw-r--r--client/src/app/shared/images/global-icon.component.scss (renamed from client/src/app/shared/icons/global-icon.component.scss)0
-rw-r--r--client/src/app/shared/images/global-icon.component.ts (renamed from client/src/app/shared/icons/global-icon.component.ts)29
-rw-r--r--client/src/app/shared/images/image-upload.component.html (renamed from client/src/app/videos/+video-edit/shared/video-image.component.html)0
-rw-r--r--client/src/app/shared/images/image-upload.component.scss (renamed from client/src/app/videos/+video-edit/shared/video-image.component.scss)0
-rw-r--r--client/src/app/shared/images/image-upload.component.ts (renamed from client/src/app/videos/+video-edit/shared/video-image.component.ts)10
-rw-r--r--client/src/app/shared/instance/instance-features-table.component.html24
-rw-r--r--client/src/app/shared/instance/instance-features-table.component.ts22
-rw-r--r--client/src/app/shared/menu/top-menu-dropdown.component.html8
-rw-r--r--client/src/app/shared/menu/top-menu-dropdown.component.scss9
-rw-r--r--client/src/app/shared/menu/top-menu-dropdown.component.ts23
-rw-r--r--client/src/app/shared/misc/help.component.html2
-rw-r--r--client/src/app/shared/misc/loader.component.html9
-rw-r--r--client/src/app/shared/misc/loader.component.scss45
-rw-r--r--client/src/app/shared/misc/loader.component.ts3
-rw-r--r--client/src/app/shared/misc/screen.service.ts6
-rw-r--r--client/src/app/shared/misc/small-loader.component.html3
-rw-r--r--client/src/app/shared/misc/small-loader.component.ts11
-rw-r--r--client/src/app/shared/misc/utils.ts6
-rw-r--r--client/src/app/shared/moderation/user-moderation-dropdown.component.scss0
-rw-r--r--client/src/app/shared/moderation/user-moderation-dropdown.component.ts3
-rw-r--r--client/src/app/shared/overview/videos-overview.model.ts4
-rw-r--r--client/src/app/shared/renderer/html-renderer.service.ts6
-rw-r--r--client/src/app/shared/renderer/linkifier.service.ts5
-rw-r--r--client/src/app/shared/renderer/markdown.service.ts34
-rw-r--r--client/src/app/shared/shared.module.ts79
-rw-r--r--client/src/app/shared/user-subscription/subscribe-button.component.ts2
-rw-r--r--client/src/app/shared/user-subscription/user-subscription.service.ts6
-rw-r--r--client/src/app/shared/users/user-notification.model.ts17
-rw-r--r--client/src/app/shared/users/user-notification.service.ts2
-rw-r--r--client/src/app/shared/users/user-notifications.component.html19
-rw-r--r--client/src/app/shared/users/user-notifications.component.scss2
-rw-r--r--client/src/app/shared/users/user-notifications.component.ts6
-rw-r--r--client/src/app/shared/users/user.model.ts11
-rw-r--r--client/src/app/shared/video-blacklist/video-blacklist.service.ts51
-rw-r--r--client/src/app/shared/video-import/video-import.service.ts5
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.html76
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.scss107
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.ts212
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html73
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss125
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts159
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-miniature.component.html34
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-miniature.component.scss78
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-miniature.component.ts22
-rw-r--r--client/src/app/shared/video-playlist/video-playlist.model.ts84
-rw-r--r--client/src/app/shared/video-playlist/video-playlist.service.ts179
-rw-r--r--client/src/app/shared/video/abstract-video-list.html19
-rw-r--r--client/src/app/shared/video/abstract-video-list.scss40
-rw-r--r--client/src/app/shared/video/abstract-video-list.ts259
-rw-r--r--client/src/app/shared/video/infinite-scroller.directive.ts61
-rw-r--r--client/src/app/shared/video/modals/video-blacklist.component.html (renamed from client/src/app/videos/+video-watch/modal/video-blacklist.component.html)0
-rw-r--r--client/src/app/shared/video/modals/video-blacklist.component.scss (renamed from client/src/app/videos/+video-watch/modal/video-blacklist.component.scss)0
-rw-r--r--client/src/app/shared/video/modals/video-blacklist.component.ts (renamed from client/src/app/videos/+video-watch/modal/video-blacklist.component.ts)13
-rw-r--r--client/src/app/shared/video/modals/video-download.component.html (renamed from client/src/app/videos/+video-watch/modal/video-download.component.html)2
-rw-r--r--client/src/app/shared/video/modals/video-download.component.scss (renamed from client/src/app/videos/+video-watch/modal/video-download.component.scss)0
-rw-r--r--client/src/app/shared/video/modals/video-download.component.ts (renamed from client/src/app/videos/+video-watch/modal/video-download.component.ts)43
-rw-r--r--client/src/app/shared/video/modals/video-report.component.html (renamed from client/src/app/videos/+video-watch/modal/video-report.component.html)0
-rw-r--r--client/src/app/shared/video/modals/video-report.component.scss (renamed from client/src/app/videos/+video-watch/modal/video-report.component.scss)0
-rw-r--r--client/src/app/shared/video/modals/video-report.component.ts (renamed from client/src/app/videos/+video-watch/modal/video-report.component.ts)3
-rw-r--r--client/src/app/shared/video/video-actions-dropdown.component.html21
-rw-r--r--client/src/app/shared/video/video-actions-dropdown.component.scss12
-rw-r--r--client/src/app/shared/video/video-actions-dropdown.component.ts241
-rw-r--r--client/src/app/shared/video/video-details.model.ts33
-rw-r--r--client/src/app/shared/video/video-edit.model.ts24
-rw-r--r--client/src/app/shared/video/video-miniature.component.html73
-rw-r--r--client/src/app/shared/video/video-miniature.component.scss173
-rw-r--r--client/src/app/shared/video/video-miniature.component.ts126
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.html8
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.scss43
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.ts11
-rw-r--r--client/src/app/shared/video/video.model.ts26
-rw-r--r--client/src/app/shared/video/video.service.ts24
-rw-r--r--client/src/app/shared/video/videos-selection.component.html26
-rw-r--r--client/src/app/shared/video/videos-selection.component.scss57
-rw-r--r--client/src/app/shared/video/videos-selection.component.ts112
-rw-r--r--client/src/app/signup/signup-routing.module.ts6
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.component.html75
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.component.ts19
-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/video-import-torrent.component.html2
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts4
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html2
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts4
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-send.ts1
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-upload.component.html2
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts6
-rw-r--r--client/src/app/videos/+video-edit/video-update.component.html2
-rw-r--r--client/src/app/videos/+video-edit/video-update.component.ts3
-rw-r--r--client/src/app/videos/+video-edit/video-update.resolver.ts51
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.component.ts4
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.model.ts7
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comments.component.html2
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comments.component.ts7
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.html12
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.scss5
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.ts11
-rw-r--r--client/src/app/videos/+video-watch/modal/video-support.component.ts4
-rw-r--r--client/src/app/videos/+video-watch/video-watch-playlist.component.html25
-rw-r--r--client/src/app/videos/+video-watch/video-watch-playlist.component.scss59
-rw-r--r--client/src/app/videos/+video-watch/video-watch-playlist.component.ts113
-rw-r--r--client/src/app/videos/+video-watch/video-watch-routing.module.ts11
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.html303
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.scss143
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts337
-rw-r--r--client/src/app/videos/+video-watch/video-watch.module.ts10
-rw-r--r--client/src/app/videos/recommendations/recent-videos-recommendation.service.spec.ts66
-rw-r--r--client/src/app/videos/recommendations/recommended-videos.component.html2
-rw-r--r--client/src/app/videos/recommendations/recommended-videos.component.ts3
-rw-r--r--client/src/app/videos/recommendations/recommended-videos.store.spec.ts22
-rw-r--r--client/src/app/videos/video-list/video-local.component.ts8
-rw-r--r--client/src/app/videos/video-list/video-overview.component.html6
-rw-r--r--client/src/app/videos/video-list/video-overview.component.scss12
-rw-r--r--client/src/app/videos/video-list/video-recently-added.component.ts10
-rw-r--r--client/src/app/videos/video-list/video-trending.component.ts11
-rw-r--r--client/src/app/videos/video-list/video-user-subscriptions.component.ts8
-rw-r--r--client/src/app/videos/videos-routing.module.ts22
259 files changed, 6283 insertions, 1859 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 8c700752e..7c27ec760 100644
--- a/client/src/app/+about/about-instance/about-instance.component.html
+++ b/client/src/app/+about/about-instance/about-instance.component.html
@@ -8,6 +8,8 @@
8 8
9 <div class="short-description"> 9 <div class="short-description">
10 <div>{{ shortDescription }}</div> 10 <div>{{ shortDescription }}</div>
11
12 <div *ngIf="isNSFW" class="dedicated-to-nsfw">This instance is dedicated to sensitive/NSFW content.</div>
11 </div> 13 </div>
12 14
13 <div class="description"> 15 <div class="description">
@@ -21,26 +23,6 @@
21 23
22 <div [innerHTML]="termsHTML"></div> 24 <div [innerHTML]="termsHTML"></div>
23 </div> 25 </div>
24
25 <div class="signup">
26 <div i18n class="section-title">Signup</div>
27
28 <div *ngIf="isSignupAllowed">
29 <ng-container i18n>User registration is allowed and</ng-container>
30
31 <ng-container i18n *ngIf="userVideoQuota !== -1">
32 this instance provides a baseline quota of {{ userVideoQuota | bytes: 0 }} space for the videos of its users.
33 </ng-container>
34
35 <ng-container i18n *ngIf="userVideoQuota === -1">
36 this instance provides unlimited space for the videos of its users.
37 </ng-container>
38 </div>
39
40 <div i18n *ngIf="isSignupAllowed === false">
41 User registration is currently not allowed.
42 </div>
43 </div>
44 </div> 26 </div>
45 27
46 <div class="col-md-12 col-xl-6"> 28 <div class="col-md-12 col-xl-6">
diff --git a/client/src/app/+about/about-instance/about-instance.component.scss b/client/src/app/+about/about-instance/about-instance.component.scss
index 75cf57322..0296ae8e9 100644
--- a/client/src/app/+about/about-instance/about-instance.component.scss
+++ b/client/src/app/+about/about-instance/about-instance.component.scss
@@ -14,6 +14,8 @@
14 & > .contact-admin { 14 & > .contact-admin {
15 @include peertube-button; 15 @include peertube-button;
16 @include orange-button; 16 @include orange-button;
17
18 height: fit-content;
17 } 19 }
18} 20}
19 21
@@ -26,3 +28,8 @@
26.short-description, .description, .terms, .signup { 28.short-description, .description, .terms, .signup {
27 margin-bottom: 30px; 29 margin-bottom: 30px;
28} 30}
31
32.short-description .dedicated-to-nsfw {
33 margin-top: 20px;
34 font-weight: $font-semibold;
35}
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 a1b30fa8c..4a63f5e38 100644
--- a/client/src/app/+about/about-instance/about-instance.component.ts
+++ b/client/src/app/+about/about-instance/about-instance.component.ts
@@ -29,25 +29,22 @@ export class AboutInstanceComponent implements OnInit {
29 return this.serverService.getConfig().instance.name 29 return this.serverService.getConfig().instance.name
30 } 30 }
31 31
32 get userVideoQuota () {
33 return this.serverService.getConfig().user.videoQuota
34 }
35
36 get isSignupAllowed () {
37 return this.serverService.getConfig().signup.allowed
38 }
39
40 get isContactFormEnabled () { 32 get isContactFormEnabled () {
41 return this.serverService.getConfig().email.enabled && this.serverService.getConfig().contactForm.enabled 33 return this.serverService.getConfig().email.enabled && this.serverService.getConfig().contactForm.enabled
42 } 34 }
43 35
36 get isNSFW () {
37 return this.serverService.getConfig().instance.isNSFW
38 }
39
44 ngOnInit () { 40 ngOnInit () {
45 this.instanceService.getAbout() 41 this.instanceService.getAbout()
46 .subscribe( 42 .subscribe(
47 res => { 43 async res => {
48 this.shortDescription = res.instance.shortDescription 44 this.shortDescription = res.instance.shortDescription
49 this.descriptionHTML = this.markdownService.textMarkdownToHTML(res.instance.description) 45
50 this.termsHTML = this.markdownService.textMarkdownToHTML(res.instance.terms) 46 this.descriptionHTML = await this.markdownService.textMarkdownToHTML(res.instance.description)
47 this.termsHTML = await this.markdownService.textMarkdownToHTML(res.instance.terms)
51 }, 48 },
52 49
53 () => this.notifier.error(this.i18n('Cannot get about information from server')) 50 () => this.notifier.error(this.i18n('Cannot get about information from server'))
diff --git a/client/src/app/+accounts/account-about/account-about.component.ts b/client/src/app/+accounts/account-about/account-about.component.ts
index 13890a0ee..ce22d3c2e 100644
--- a/client/src/app/+accounts/account-about/account-about.component.ts
+++ b/client/src/app/+accounts/account-about/account-about.component.ts
@@ -25,9 +25,9 @@ export class AccountAboutComponent implements OnInit, OnDestroy {
25 ngOnInit () { 25 ngOnInit () {
26 // Parent get the account for us 26 // Parent get the account for us
27 this.accountSub = this.accountService.accountLoaded 27 this.accountSub = this.accountService.accountLoaded
28 .subscribe(account => { 28 .subscribe(async account => {
29 this.account = account 29 this.account = account
30 this.descriptionHTML = this.markdownService.textMarkdownToHTML(this.account.description) 30 this.descriptionHTML = await this.markdownService.textMarkdownToHTML(this.account.description)
31 }) 31 })
32 } 32 }
33 33
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 13b634a01..0d579fa0c 100644
--- a/client/src/app/+accounts/account-videos/account-videos.component.ts
+++ b/client/src/app/+accounts/account-videos/account-videos.component.ts
@@ -1,6 +1,5 @@
1import { Component, OnDestroy, OnInit } from '@angular/core' 1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { Location } from '@angular/common'
4import { immutableAssign } from '@app/shared/misc/utils' 3import { immutableAssign } from '@app/shared/misc/utils'
5import { AuthService } from '../../core/auth' 4import { AuthService } from '../../core/auth'
6import { ConfirmService } from '../../core/confirm' 5import { ConfirmService } from '../../core/confirm'
@@ -8,11 +7,11 @@ import { AbstractVideoList } from '../../shared/video/abstract-video-list'
8import { VideoService } from '../../shared/video/video.service' 7import { VideoService } from '../../shared/video/video.service'
9import { Account } from '@app/shared/account/account.model' 8import { Account } from '@app/shared/account/account.model'
10import { AccountService } from '@app/shared/account/account.service' 9import { AccountService } from '@app/shared/account/account.service'
11import { tap } from 'rxjs/operators' 10import { first, tap } from 'rxjs/operators'
12import { I18n } from '@ngx-translate/i18n-polyfill' 11import { I18n } from '@ngx-translate/i18n-polyfill'
13import { Subscription } from 'rxjs' 12import { Subscription } from 'rxjs'
14import { ScreenService } from '@app/shared/misc/screen.service' 13import { ScreenService } from '@app/shared/misc/screen.service'
15import { Notifier } from '@app/core' 14import { Notifier, ServerService } from '@app/core'
16 15
17@Component({ 16@Component({
18 selector: 'my-account-videos', 17 selector: 'my-account-videos',
@@ -24,8 +23,6 @@ import { Notifier } from '@app/core'
24}) 23})
25export class AccountVideosComponent extends AbstractVideoList implements OnInit, OnDestroy { 24export class AccountVideosComponent extends AbstractVideoList implements OnInit, OnDestroy {
26 titlePage: string 25 titlePage: string
27 marginContent = false // Disable margin
28 currentRoute = '/accounts/videos'
29 loadOnInit = false 26 loadOnInit = false
30 27
31 private account: Account 28 private account: Account
@@ -33,13 +30,13 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit,
33 30
34 constructor ( 31 constructor (
35 protected router: Router, 32 protected router: Router,
33 protected serverService: ServerService,
36 protected route: ActivatedRoute, 34 protected route: ActivatedRoute,
37 protected authService: AuthService, 35 protected authService: AuthService,
38 protected notifier: Notifier, 36 protected notifier: Notifier,
39 protected confirmService: ConfirmService, 37 protected confirmService: ConfirmService,
40 protected location: Location,
41 protected screenService: ScreenService, 38 protected screenService: ScreenService,
42 protected i18n: I18n, 39 private i18n: I18n,
43 private accountService: AccountService, 40 private accountService: AccountService,
44 private videoService: VideoService 41 private videoService: VideoService
45 ) { 42 ) {
@@ -53,13 +50,13 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit,
53 50
54 // Parent get the account for us 51 // Parent get the account for us
55 this.accountSub = this.accountService.accountLoaded 52 this.accountSub = this.accountService.accountLoaded
56 .subscribe(account => { 53 .pipe(first())
57 this.account = account 54 .subscribe(account => {
58 this.currentRoute = '/accounts/' + this.account.nameWithHost + '/videos' 55 this.account = account
59 56
60 this.reloadVideos() 57 this.reloadVideos()
61 this.generateSyndicationList() 58 this.generateSyndicationList()
62 }) 59 })
63 } 60 }
64 61
65 ngOnDestroy () { 62 ngOnDestroy () {
diff --git a/client/src/app/+accounts/accounts-routing.module.ts b/client/src/app/+accounts/accounts-routing.module.ts
index ffe606b43..531d763c4 100644
--- a/client/src/app/+accounts/accounts-routing.module.ts
+++ b/client/src/app/+accounts/accounts-routing.module.ts
@@ -23,6 +23,10 @@ const accountsRoutes: Routes = [
23 data: { 23 data: {
24 meta: { 24 meta: {
25 title: 'Account videos' 25 title: 'Account videos'
26 },
27 reuse: {
28 enabled: true,
29 key: 'account-videos-list'
26 } 30 }
27 } 31 }
28 }, 32 },
diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts
index e8339b78b..d9786fb5c 100644
--- a/client/src/app/+accounts/accounts.component.ts
+++ b/client/src/app/+accounts/accounts.component.ts
@@ -7,7 +7,6 @@ import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/oper
7import { 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'
11 10
12@Component({ 11@Component({
13 templateUrl: './accounts.component.html', 12 templateUrl: './accounts.component.html',
diff --git a/client/src/app/+admin/admin-routing.module.ts b/client/src/app/+admin/admin-routing.module.ts
index ca31ba585..215da1e4f 100644
--- a/client/src/app/+admin/admin-routing.module.ts
+++ b/client/src/app/+admin/admin-routing.module.ts
@@ -6,9 +6,9 @@ import { MetaGuard } from '@ngx-meta/core'
6 6
7import { AdminComponent } from './admin.component' 7import { AdminComponent } from './admin.component'
8import { FollowsRoutes } from './follows' 8import { FollowsRoutes } from './follows'
9import { JobsRoutes } from './jobs/job.routes'
10import { UsersRoutes } from './users' 9import { UsersRoutes } from './users'
11import { ModerationRoutes } from '@app/+admin/moderation/moderation.routes' 10import { ModerationRoutes } from '@app/+admin/moderation/moderation.routes'
11import { SystemRoutes } from '@app/+admin/system'
12 12
13const adminRoutes: Routes = [ 13const adminRoutes: Routes = [
14 { 14 {
@@ -25,7 +25,7 @@ const adminRoutes: Routes = [
25 ...FollowsRoutes, 25 ...FollowsRoutes,
26 ...UsersRoutes, 26 ...UsersRoutes,
27 ...ModerationRoutes, 27 ...ModerationRoutes,
28 ...JobsRoutes, 28 ...SystemRoutes,
29 ...ConfigRoutes 29 ...ConfigRoutes
30 ] 30 ]
31 } 31 }
diff --git a/client/src/app/+admin/admin.component.html b/client/src/app/+admin/admin.component.html
index 345fb9f5a..98f45a7d1 100644
--- a/client/src/app/+admin/admin.component.html
+++ b/client/src/app/+admin/admin.component.html
@@ -12,13 +12,13 @@
12 Moderation 12 Moderation
13 </a> 13 </a>
14 14
15 <a i18n *ngIf="hasJobsRight()" routerLink="/admin/jobs" routerLinkActive="active" class="title-page">
16 Jobs
17 </a>
18
19 <a i18n *ngIf="hasConfigRight()" routerLink="/admin/config" routerLinkActive="active" class="title-page"> 15 <a i18n *ngIf="hasConfigRight()" routerLink="/admin/config" routerLinkActive="active" class="title-page">
20 Configuration 16 Configuration
21 </a> 17 </a>
18
19 <a i18n *ngIf="hasJobsRight() || hasLogsRight() || hasDebugRight()" routerLink="/admin/system" routerLinkActive="active" class="title-page">
20 System
21 </a>
22 </div> 22 </div>
23 23
24 <div class="margin-content"> 24 <div class="margin-content">
diff --git a/client/src/app/+admin/admin.component.scss b/client/src/app/+admin/admin.component.scss
deleted file mode 100644
index e69de29bb..000000000
--- a/client/src/app/+admin/admin.component.scss
+++ /dev/null
diff --git a/client/src/app/+admin/admin.component.ts b/client/src/app/+admin/admin.component.ts
index 1a4dd6786..408de4837 100644
--- a/client/src/app/+admin/admin.component.ts
+++ b/client/src/app/+admin/admin.component.ts
@@ -3,8 +3,7 @@ import { UserRight } from '../../../../shared'
3import { AuthService } from '../core/auth/auth.service' 3import { AuthService } from '../core/auth/auth.service'
4 4
5@Component({ 5@Component({
6 templateUrl: './admin.component.html', 6 templateUrl: './admin.component.html'
7 styleUrls: [ './admin.component.scss' ]
8}) 7})
9export class AdminComponent { 8export class AdminComponent {
10 constructor (private auth: AuthService) {} 9 constructor (private auth: AuthService) {}
@@ -25,11 +24,19 @@ export class AdminComponent {
25 return this.auth.getUser().hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) 24 return this.auth.getUser().hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)
26 } 25 }
27 26
27 hasConfigRight () {
28 return this.auth.getUser().hasRight(UserRight.MANAGE_CONFIGURATION)
29 }
30
31 hasLogsRight () {
32 return this.auth.getUser().hasRight(UserRight.MANAGE_LOGS)
33 }
34
28 hasJobsRight () { 35 hasJobsRight () {
29 return this.auth.getUser().hasRight(UserRight.MANAGE_JOBS) 36 return this.auth.getUser().hasRight(UserRight.MANAGE_JOBS)
30 } 37 }
31 38
32 hasConfigRight () { 39 hasDebugRight () {
33 return this.auth.getUser().hasRight(UserRight.MANAGE_CONFIGURATION) 40 return this.auth.getUser().hasRight(UserRight.MANAGE_DEBUG)
34 } 41 }
35} 42}
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts
index c06ae1d60..71a4dfc4a 100644
--- a/client/src/app/+admin/admin.module.ts
+++ b/client/src/app/+admin/admin.module.ts
@@ -7,15 +7,20 @@ import { AdminRoutingModule } from './admin-routing.module'
7import { AdminComponent } from './admin.component' 7import { AdminComponent } from './admin.component'
8import { FollowersListComponent, FollowingAddComponent, FollowsComponent, FollowService } from './follows' 8import { FollowersListComponent, FollowingAddComponent, FollowsComponent, FollowService } from './follows'
9import { FollowingListComponent } from './follows/following-list/following-list.component' 9import { FollowingListComponent } from './follows/following-list/following-list.component'
10import { JobsComponent } from './jobs/job.component' 10import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users'
11import { JobsListComponent } from './jobs/jobs-list/jobs-list.component' 11import {
12import { JobService } from './jobs/shared/job.service' 12 ModerationCommentModalComponent,
13import { UserCreateComponent, UserListComponent, UsersComponent, UserUpdateComponent } from './users' 13 VideoAbuseListComponent,
14import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlacklistListComponent } from './moderation' 14 VideoAutoBlacklistListComponent,
15 VideoBlacklistListComponent
16} from './moderation'
15import { ModerationComponent } from '@app/+admin/moderation/moderation.component' 17import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
16import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component' 18import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component'
17import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service' 19import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service'
18import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' 20import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
21import { JobsComponent } from '@app/+admin/system/jobs/jobs.component'
22import { JobService, LogsComponent, LogsService, SystemComponent } from '@app/+admin/system'
23import { DebugComponent, DebugService } from '@app/+admin/system/debug'
19 24
20@NgModule({ 25@NgModule({
21 imports: [ 26 imports: [
@@ -36,17 +41,21 @@ import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } f
36 UsersComponent, 41 UsersComponent,
37 UserCreateComponent, 42 UserCreateComponent,
38 UserUpdateComponent, 43 UserUpdateComponent,
44 UserPasswordComponent,
39 UserListComponent, 45 UserListComponent,
40 46
41 ModerationComponent, 47 ModerationComponent,
42 VideoBlacklistListComponent, 48 VideoBlacklistListComponent,
43 VideoAbuseListComponent, 49 VideoAbuseListComponent,
50 VideoAutoBlacklistListComponent,
44 ModerationCommentModalComponent, 51 ModerationCommentModalComponent,
45 InstanceServerBlocklistComponent, 52 InstanceServerBlocklistComponent,
46 InstanceAccountBlocklistComponent, 53 InstanceAccountBlocklistComponent,
47 54
55 SystemComponent,
48 JobsComponent, 56 JobsComponent,
49 JobsListComponent, 57 LogsComponent,
58 DebugComponent,
50 59
51 ConfigComponent, 60 ConfigComponent,
52 EditCustomConfigComponent 61 EditCustomConfigComponent
@@ -60,6 +69,8 @@ import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } f
60 FollowService, 69 FollowService,
61 RedundancyService, 70 RedundancyService,
62 JobService, 71 JobService,
72 LogsService,
73 DebugService,
63 ConfigService 74 ConfigService
64 ] 75 ]
65}) 76})
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 52eb00d93..637484622 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
@@ -45,6 +45,15 @@
45 </div> 45 </div>
46 46
47 <div class="form-group"> 47 <div class="form-group">
48 <my-peertube-checkbox
49 inputName="instanceIsNSFW" formControlName="isNSFW"
50 i18n-labelText labelText="Dedicated to sensitive or NSFW content"
51 i18n-helpHtml helpHtml="Enabling it will allow other administrators to know that you are mainly federating sensitive content.<br /><br />
52 Moreover, the NSFW checkbox on video upload will be automatically checked by default."
53 ></my-peertube-checkbox>
54 </div>
55
56 <div class="form-group">
48 <label i18n for="instanceDefaultClientRoute">Default client route</label> 57 <label i18n for="instanceDefaultClientRoute">Default client route</label>
49 <div class="peertube-select-container"> 58 <div class="peertube-select-container">
50 <select id="instanceDefaultClientRoute" formControlName="defaultClientRoute"> 59 <select id="instanceDefaultClientRoute" formControlName="defaultClientRoute">
@@ -75,6 +84,7 @@
75 </div> 84 </div>
76 </ng-container> 85 </ng-container>
77 86
87
78 <div i18n class="inner-form-title">Signup</div> 88 <div i18n class="inner-form-title">Signup</div>
79 89
80 <ng-container formGroupName="signup"> 90 <ng-container formGroupName="signup">
@@ -102,6 +112,7 @@
102 </div> 112 </div>
103 </ng-container> 113 </ng-container>
104 114
115
105 <div i18n class="inner-form-title">Users</div> 116 <div i18n class="inner-form-title">Users</div>
106 117
107 <ng-container formGroupName="user"> 118 <ng-container formGroupName="user">
@@ -130,6 +141,7 @@
130 </div> 141 </div>
131 </ng-container> 142 </ng-container>
132 143
144
133 <div i18n class="inner-form-title">Import</div> 145 <div i18n class="inner-form-title">Import</div>
134 146
135 <ng-container formGroupName="import"> 147 <ng-container formGroupName="import">
@@ -152,6 +164,47 @@
152 </ng-container> 164 </ng-container>
153 </ng-container> 165 </ng-container>
154 166
167
168 <div i18n class="inner-form-title">Auto-blacklist</div>
169
170 <ng-container formGroupName="autoBlacklist">
171 <ng-container formGroupName="videos">
172 <ng-container formGroupName="ofUsers">
173
174 <div class="form-group">
175 <my-peertube-checkbox
176 inputName="autoBlacklistVideosOfUsersEnabled" formControlName="enabled"
177 i18n-labelText labelText="New videos of users automatically blacklisted enabled"
178 ></my-peertube-checkbox>
179 </div>
180
181 </ng-container>
182 </ng-container>
183 </ng-container>
184
185
186 <div i18n class="inner-form-title">Instance followers</div>
187
188 <ng-container formGroupName="followers">
189 <ng-container formGroupName="instance">
190
191 <div class="form-group">
192 <my-peertube-checkbox
193 inputName="followersInstanceEnabled" formControlName="enabled"
194 i18n-labelText labelText="Other instances can follow your instance"
195 ></my-peertube-checkbox>
196 </div>
197
198 <div class="form-group">
199 <my-peertube-checkbox
200 inputName="followersInstanceManualApproval" formControlName="manualApproval"
201 i18n-labelText labelText="Manually approve new instance follower"
202 ></my-peertube-checkbox>
203 </div>
204 </ng-container>
205 </ng-container>
206
207
155 <div i18n class="inner-form-title">Administrator</div> 208 <div i18n class="inner-form-title">Administrator</div>
156 209
157 <div class="form-group" formGroupName="admin"> 210 <div class="form-group" formGroupName="admin">
@@ -309,18 +362,18 @@
309 helpType="custom" 362 helpType="custom"
310 i18n-customHtml 363 i18n-customHtml
311 customHtml=" 364 customHtml="
312 Write directly CSS code. Example:<br /> 365 Write directly CSS code. Example:<br /><br />
313 <pre> 366 <pre>
314 body {{ '{' }} 367 #custom-css {{ '{' }}
315 background-color: red; 368 color: red;
316 {{ '}' }} 369 {{ '}' }}
317 </pre> 370 </pre>
318 371
319 Prepend with <em>#custom-css</em> to override styles. Example: 372 Prepend with <em>#custom-css</em> to override styles. Example:<br /><br />
320 <pre> 373 <pre>
321 #custom-css .logged-in-email {{ '{' }} 374 #custom-css .logged-in-email {{ '{' }}
322 color: red; 375 color: red;
323 {{ '}' }} 376 {{ '}' }}
324 </pre> 377 </pre>
325 " 378 "
326 ></my-help> 379 ></my-help>
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 654a076b0..e64750713 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
@@ -5,7 +5,7 @@ import { CustomConfigValidatorsService, FormReactive, UserValidatorsService } fr
5import { Notifier } from '@app/core' 5import { Notifier } from '@app/core'
6import { CustomConfig } from '../../../../../../shared/models/server/custom-config.model' 6import { CustomConfig } from '../../../../../../shared/models/server/custom-config.model'
7import { I18n } from '@ngx-translate/i18n-polyfill' 7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { BuildFormDefaultValues, FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 8import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
9 9
10@Component({ 10@Component({
11 selector: 'my-edit-custom-config', 11 selector: 'my-edit-custom-config',
@@ -66,6 +66,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
66 description: null, 66 description: null,
67 terms: null, 67 terms: null,
68 defaultClientRoute: null, 68 defaultClientRoute: null,
69 isNSFW: false,
69 defaultNSFWPolicy: null, 70 defaultNSFWPolicy: null,
70 customizations: { 71 customizations: {
71 javascript: null, 72 javascript: null,
@@ -116,6 +117,19 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
116 threads: this.customConfigValidatorsService.TRANSCODING_THREADS, 117 threads: this.customConfigValidatorsService.TRANSCODING_THREADS,
117 allowAdditionalExtensions: null, 118 allowAdditionalExtensions: null,
118 resolutions: {} 119 resolutions: {}
120 },
121 autoBlacklist: {
122 videos: {
123 ofUsers: {
124 enabled: null
125 }
126 }
127 },
128 followers: {
129 instance: {
130 enabled: null,
131 manualApproval: null
132 }
119 } 133 }
120 } 134 }
121 135
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 fc022bdb4..da0ba95e3 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
@@ -14,25 +14,33 @@
14 <ng-template pTemplate="header"> 14 <ng-template pTemplate="header">
15 <tr> 15 <tr>
16 <th i18n style="width: 60px">ID</th> 16 <th i18n style="width: 60px">ID</th>
17 <th i18n>Score</th> 17 <th i18n>Follower handle</th>
18 <th i18n>Name</th>
19 <th i18n>Host</th>
20 <th i18n>State</th> 18 <th i18n>State</th>
19 <th i18n>Score</th>
21 <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> 20 <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
21 <th></th>
22 </tr> 22 </tr>
23 </ng-template> 23 </ng-template>
24 24
25 <ng-template pTemplate="body" let-follow> 25 <ng-template pTemplate="body" let-follow>
26 <tr> 26 <tr>
27 <td>{{ follow.id }}</td> 27 <td>{{ follow.id }}</td>
28 <td>{{ follow.score }}</td> 28 <td>{{ follow.follower.name + '@' + follow.follower.host }}</td>
29 <td>{{ follow.follower.name }}</td>
30 <td>{{ follow.follower.host }}</td>
31 29
32 <td *ngIf="follow.state === 'accepted'" i18n>Accepted</td> 30 <td *ngIf="follow.state === 'accepted'" i18n>Accepted</td>
33 <td *ngIf="follow.state === 'pending'" i18n>Pending</td> 31 <td *ngIf="follow.state === 'pending'" i18n>Pending</td>
34 32
33 <td>{{ follow.score }}</td>
35 <td>{{ follow.createdAt }}</td> 34 <td>{{ follow.createdAt }}</td>
35
36 <td class="action-cell">
37 <ng-container *ngIf="follow.state === 'pending'">
38 <my-button i18n-label label="Accept" icon="tick" (click)="acceptFollower(follow)"></my-button>
39 <my-button i18n-label label="Refuse" icon="cross" (click)="rejectFollower(follow)"></my-button>
40 </ng-container>
41
42 <my-delete-button *ngIf="follow.state === 'accepted'" (click)="deleteFollower(follow)"></my-delete-button>
43 </td>
36 </tr> 44 </tr>
37 </ng-template> 45 </ng-template>
38</p-table> 46</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 a6f0656b8..964b3f99b 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
@@ -7,4 +7,10 @@
7 input { 7 input {
8 @include peertube-input-text(250px); 8 @include peertube-input-text(250px);
9 } 9 }
10} \ No newline at end of file 10}
11
12.action-cell {
13 my-button:first-child {
14 margin-right: 10px;
15 }
16}
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 9a8848bfb..b78cdf656 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
@@ -1,6 +1,5 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2 2import { ConfirmService, Notifier } from '@app/core'
3import { Notifier } from '@app/core'
4import { SortMeta } from 'primeng/primeng' 3import { SortMeta } from 'primeng/primeng'
5import { ActorFollow } from '../../../../../../shared/models/actors/follow.model' 4import { ActorFollow } from '../../../../../../shared/models/actors/follow.model'
6import { RestPagination, RestTable } from '../../../shared' 5import { RestPagination, RestTable } from '../../../shared'
@@ -20,9 +19,10 @@ export class FollowersListComponent extends RestTable implements OnInit {
20 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 19 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
21 20
22 constructor ( 21 constructor (
22 private confirmService: ConfirmService,
23 private notifier: Notifier, 23 private notifier: Notifier,
24 private followService: FollowService, 24 private i18n: I18n,
25 private i18n: I18n 25 private followService: FollowService
26 ) { 26 ) {
27 super() 27 super()
28 } 28 }
@@ -31,6 +31,62 @@ export class FollowersListComponent extends RestTable implements OnInit {
31 this.initialize() 31 this.initialize()
32 } 32 }
33 33
34 acceptFollower (follow: ActorFollow) {
35 follow.state = 'accepted'
36
37 this.followService.acceptFollower(follow)
38 .subscribe(
39 () => {
40 const handle = follow.follower.name + '@' + follow.follower.host
41 this.notifier.success(this.i18n('{{handle}} accepted in instance followers', { handle }))
42 },
43
44 err => {
45 follow.state = 'pending'
46 this.notifier.error(err.message)
47 }
48 )
49 }
50
51 async rejectFollower (follow: ActorFollow) {
52 const message = this.i18n('Do you really want to reject this follower?')
53 const res = await this.confirmService.confirm(message, this.i18n('Reject'))
54 if (res === false) return
55
56 this.followService.rejectFollower(follow)
57 .subscribe(
58 () => {
59 const handle = follow.follower.name + '@' + follow.follower.host
60 this.notifier.success(this.i18n('{{handle}} rejected from instance followers', { handle }))
61
62 this.loadData()
63 },
64
65 err => {
66 follow.state = 'pending'
67 this.notifier.error(err.message)
68 }
69 )
70 }
71
72 async deleteFollower (follow: ActorFollow) {
73 const message = this.i18n('Do you really want to delete this follower?')
74 const res = await this.confirmService.confirm(message, this.i18n('Delete'))
75 if (res === false) return
76
77 this.followService.removeFollower(follow)
78 .subscribe(
79 () => {
80 const handle = follow.follower.name + '@' + follow.follower.host
81 this.notifier.success(this.i18n('{{handle}} removed from instance followers', { handle }))
82
83 this.loadData()
84 },
85
86 err => this.notifier.error(err.message)
87 )
88 }
89
34 protected loadData () { 90 protected loadData () {
35 this.followService.getFollowers(this.pagination, this.sort, this.search) 91 this.followService.getFollowers(this.pagination, this.sort, this.search)
36 .subscribe( 92 .subscribe(
diff --git a/client/src/app/+admin/follows/shared/follow.service.ts b/client/src/app/+admin/follows/shared/follow.service.ts
index a2904179e..c2b8ef006 100644
--- a/client/src/app/+admin/follows/shared/follow.service.ts
+++ b/client/src/app/+admin/follows/shared/follow.service.ts
@@ -63,4 +63,34 @@ export class FollowService {
63 catchError(res => this.restExtractor.handleError(res)) 63 catchError(res => this.restExtractor.handleError(res))
64 ) 64 )
65 } 65 }
66
67 acceptFollower (follow: ActorFollow) {
68 const handle = follow.follower.name + '@' + follow.follower.host
69
70 return this.authHttp.post(`${FollowService.BASE_APPLICATION_URL}/followers/${handle}/accept`, {})
71 .pipe(
72 map(this.restExtractor.extractDataBool),
73 catchError(res => this.restExtractor.handleError(res))
74 )
75 }
76
77 rejectFollower (follow: ActorFollow) {
78 const handle = follow.follower.name + '@' + follow.follower.host
79
80 return this.authHttp.post(`${FollowService.BASE_APPLICATION_URL}/followers/${handle}/reject`, {})
81 .pipe(
82 map(this.restExtractor.extractDataBool),
83 catchError(res => this.restExtractor.handleError(res))
84 )
85 }
86
87 removeFollower (follow: ActorFollow) {
88 const handle = follow.follower.name + '@' + follow.follower.host
89
90 return this.authHttp.delete(`${FollowService.BASE_APPLICATION_URL}/followers/${handle}`)
91 .pipe(
92 map(this.restExtractor.extractDataBool),
93 catchError(res => this.restExtractor.handleError(res))
94 )
95 }
66} 96}
diff --git a/client/src/app/+admin/jobs/index.ts b/client/src/app/+admin/jobs/index.ts
deleted file mode 100644
index c0e0cc95d..000000000
--- a/client/src/app/+admin/jobs/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
1export * from './shared'
2export * from './jobs-list'
3export * from './job.routes'
4export * from './job.component'
diff --git a/client/src/app/+admin/jobs/job.component.ts b/client/src/app/+admin/jobs/job.component.ts
deleted file mode 100644
index bc80c9a6a..000000000
--- a/client/src/app/+admin/jobs/job.component.ts
+++ /dev/null
@@ -1,6 +0,0 @@
1import { Component } from '@angular/core'
2
3@Component({
4 template: '<router-outlet></router-outlet>'
5})
6export class JobsComponent {}
diff --git a/client/src/app/+admin/jobs/job.routes.ts b/client/src/app/+admin/jobs/job.routes.ts
deleted file mode 100644
index 331dc2af2..000000000
--- a/client/src/app/+admin/jobs/job.routes.ts
+++ /dev/null
@@ -1,32 +0,0 @@
1import { Routes } from '@angular/router'
2import { UserRight } from '../../../../../shared'
3import { UserRightGuard } from '../../core'
4import { JobsComponent } from './job.component'
5import { JobsListComponent } from './jobs-list/jobs-list.component'
6
7export const JobsRoutes: Routes = [
8 {
9 path: 'jobs',
10 component: JobsComponent,
11 canActivate: [ UserRightGuard ],
12 data: {
13 userRight: UserRight.MANAGE_JOBS
14 },
15 children: [
16 {
17 path: '',
18 redirectTo: 'list',
19 pathMatch: 'full'
20 },
21 {
22 path: 'list',
23 component: JobsListComponent,
24 data: {
25 meta: {
26 title: 'Jobs list'
27 }
28 }
29 }
30 ]
31 }
32]
diff --git a/client/src/app/+admin/jobs/jobs-list/index.ts b/client/src/app/+admin/jobs/jobs-list/index.ts
deleted file mode 100644
index cf590a6f8..000000000
--- a/client/src/app/+admin/jobs/jobs-list/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './jobs-list.component'
diff --git a/client/src/app/+admin/jobs/shared/index.ts b/client/src/app/+admin/jobs/shared/index.ts
deleted file mode 100644
index 609439e5c..000000000
--- a/client/src/app/+admin/jobs/shared/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './job.service'
diff --git a/client/src/app/+admin/moderation/index.ts b/client/src/app/+admin/moderation/index.ts
index 66e2c6a39..3c683a28c 100644
--- a/client/src/app/+admin/moderation/index.ts
+++ b/client/src/app/+admin/moderation/index.ts
@@ -1,4 +1,5 @@
1export * from './video-abuse-list' 1export * from './video-abuse-list'
2export * from './video-auto-blacklist-list'
2export * from './video-blacklist-list' 3export * from './video-blacklist-list'
3export * from './moderation.component' 4export * from './moderation.component'
4export * from './moderation.routes' 5export * from './moderation.routes'
diff --git a/client/src/app/+admin/moderation/moderation.component.html b/client/src/app/+admin/moderation/moderation.component.html
index 01457936c..b70027957 100644
--- a/client/src/app/+admin/moderation/moderation.component.html
+++ b/client/src/app/+admin/moderation/moderation.component.html
@@ -4,7 +4,9 @@
4 <div class="admin-sub-nav"> 4 <div class="admin-sub-nav">
5 <a *ngIf="hasVideoAbusesRight()" i18n routerLink="video-abuses/list" routerLinkActive="active">Video abuses</a> 5 <a *ngIf="hasVideoAbusesRight()" i18n routerLink="video-abuses/list" routerLinkActive="active">Video abuses</a>
6 6
7 <a *ngIf="hasVideoBlacklistRight()" i18n routerLink="video-blacklist/list" routerLinkActive="active">Blacklisted videos</a> 7 <a *ngIf="hasVideoBlacklistRight()" i18n routerLink="video-blacklist/list" routerLinkActive="active">{{ autoBlacklistVideosEnabled ? 'Manually blacklisted videos' : 'Blacklisted videos' }}</a>
8
9 <a *ngIf="autoBlacklistVideosEnabled && hasVideoBlacklistRight()" i18n routerLink="video-auto-blacklist/list" routerLinkActive="active">Auto-blacklisted videos</a>
8 10
9 <a *ngIf="hasAccountsBlocklistRight()" i18n routerLink="blocklist/accounts" routerLinkActive="active">Muted accounts</a> 11 <a *ngIf="hasAccountsBlocklistRight()" i18n routerLink="blocklist/accounts" routerLinkActive="active">Muted accounts</a>
10 12
diff --git a/client/src/app/+admin/moderation/moderation.component.ts b/client/src/app/+admin/moderation/moderation.component.ts
index 2b2618933..47154af3f 100644
--- a/client/src/app/+admin/moderation/moderation.component.ts
+++ b/client/src/app/+admin/moderation/moderation.component.ts
@@ -1,13 +1,20 @@
1import { Component } from '@angular/core' 1import { Component } from '@angular/core'
2import { UserRight } from '../../../../../shared' 2import { UserRight } from '../../../../../shared'
3import { AuthService } from '@app/core/auth/auth.service' 3import { AuthService, ServerService } from '@app/core'
4 4
5@Component({ 5@Component({
6 templateUrl: './moderation.component.html', 6 templateUrl: './moderation.component.html',
7 styleUrls: [ './moderation.component.scss' ] 7 styleUrls: [ './moderation.component.scss' ]
8}) 8})
9export class ModerationComponent { 9export class ModerationComponent {
10 constructor (private auth: AuthService) {} 10 autoBlacklistVideosEnabled: boolean
11
12 constructor (
13 private auth: AuthService,
14 private serverService: ServerService
15 ) {
16 this.autoBlacklistVideosEnabled = this.serverService.getConfig().autoBlacklist.videos.ofUsers.enabled
17 }
11 18
12 hasVideoAbusesRight () { 19 hasVideoAbusesRight () {
13 return this.auth.getUser().hasRight(UserRight.MANAGE_VIDEO_ABUSES) 20 return this.auth.getUser().hasRight(UserRight.MANAGE_VIDEO_ABUSES)
diff --git a/client/src/app/+admin/moderation/moderation.routes.ts b/client/src/app/+admin/moderation/moderation.routes.ts
index 6f6dde290..a024f2bee 100644
--- a/client/src/app/+admin/moderation/moderation.routes.ts
+++ b/client/src/app/+admin/moderation/moderation.routes.ts
@@ -3,6 +3,7 @@ import { UserRight } from '../../../../../shared'
3import { UserRightGuard } from '@app/core' 3import { UserRightGuard } from '@app/core'
4import { VideoAbuseListComponent } from '@app/+admin/moderation/video-abuse-list' 4import { VideoAbuseListComponent } from '@app/+admin/moderation/video-abuse-list'
5import { VideoBlacklistListComponent } from '@app/+admin/moderation/video-blacklist-list' 5import { VideoBlacklistListComponent } from '@app/+admin/moderation/video-blacklist-list'
6import { VideoAutoBlacklistListComponent } from '@app/+admin/moderation/video-auto-blacklist-list'
6import { ModerationComponent } from '@app/+admin/moderation/moderation.component' 7import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
7import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' 8import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
8 9
@@ -27,6 +28,11 @@ export const ModerationRoutes: Routes = [
27 pathMatch: 'full' 28 pathMatch: 'full'
28 }, 29 },
29 { 30 {
31 path: 'video-auto-blacklist',
32 redirectTo: 'video-auto-blacklist/list',
33 pathMatch: 'full'
34 },
35 {
30 path: 'video-abuses/list', 36 path: 'video-abuses/list',
31 component: VideoAbuseListComponent, 37 component: VideoAbuseListComponent,
32 canActivate: [ UserRightGuard ], 38 canActivate: [ UserRightGuard ],
@@ -38,6 +44,17 @@ export const ModerationRoutes: Routes = [
38 } 44 }
39 }, 45 },
40 { 46 {
47 path: 'video-auto-blacklist/list',
48 component: VideoAutoBlacklistListComponent,
49 canActivate: [ UserRightGuard ],
50 data: {
51 userRight: UserRight.MANAGE_VIDEO_BLACKLIST,
52 meta: {
53 title: 'Auto-blacklisted videos'
54 }
55 }
56 },
57 {
41 path: 'video-blacklist/list', 58 path: 'video-blacklist/list',
42 component: VideoBlacklistListComponent, 59 component: VideoBlacklistListComponent,
43 canActivate: [ UserRightGuard ], 60 canActivate: [ UserRightGuard ],
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 05b549de6..627437053 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
@@ -51,11 +51,11 @@
51 <td class="moderation-expanded" colspan="6"> 51 <td class="moderation-expanded" colspan="6">
52 <div> 52 <div>
53 <span i18n class="moderation-expanded-label">Reason:</span> 53 <span i18n class="moderation-expanded-label">Reason:</span>
54 <span class="moderation-expanded-text" [innerHTML]="toHtml(videoAbuse.reason)"></span> 54 <span class="moderation-expanded-text" [innerHTML]="videoAbuse.reasonHtml"></span>
55 </div> 55 </div>
56 <div *ngIf="videoAbuse.moderationComment"> 56 <div *ngIf="videoAbuse.moderationComment">
57 <span i18n class="moderation-expanded-label">Moderation comment:</span> 57 <span i18n class="moderation-expanded-label">Moderation comment:</span>
58 <span class="moderation-expanded-text" [innerHTML]="toHtml(videoAbuse.moderationComment)"></span> 58 <span class="moderation-expanded-text" [innerHTML]="videoAbuse.moderationCommentHtml"></span>
59 </div> 59 </div>
60 </td> 60 </td>
61 </tr> 61 </tr>
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 00c871659..3aa875668 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
@@ -19,7 +19,7 @@ import { MarkdownService } from '@app/shared/renderer'
19export class VideoAbuseListComponent extends RestTable implements OnInit { 19export class VideoAbuseListComponent extends RestTable implements OnInit {
20 @ViewChild('moderationCommentModal') moderationCommentModal: ModerationCommentModalComponent 20 @ViewChild('moderationCommentModal') moderationCommentModal: ModerationCommentModalComponent
21 21
22 videoAbuses: VideoAbuse[] = [] 22 videoAbuses: (VideoAbuse & { moderationCommentHtml?: string, reasonHtml?: string })[] = []
23 totalRecords = 0 23 totalRecords = 0
24 rowsPerPage = 10 24 rowsPerPage = 10
25 sort: SortMeta = { field: 'createdAt', order: 1 } 25 sort: SortMeta = { field: 'createdAt', order: 1 }
@@ -110,19 +110,28 @@ export class VideoAbuseListComponent extends RestTable implements OnInit {
110 110
111 } 111 }
112 112
113 toHtml (text: string) {
114 return this.markdownRenderer.textMarkdownToHTML(text)
115 }
116
117 protected loadData () { 113 protected loadData () {
118 return this.videoAbuseService.getVideoAbuses(this.pagination, this.sort) 114 return this.videoAbuseService.getVideoAbuses(this.pagination, this.sort)
119 .subscribe( 115 .subscribe(
120 resultList => { 116 async resultList => {
121 this.videoAbuses = resultList.data
122 this.totalRecords = resultList.total 117 this.totalRecords = resultList.total
118
119 this.videoAbuses = resultList.data
120
121 for (const abuse of this.videoAbuses) {
122 Object.assign(abuse, {
123 reasonHtml: await this.toHtml(abuse.reason),
124 moderationCommentHtml: await this.toHtml(abuse.moderationComment)
125 })
126 }
127
123 }, 128 },
124 129
125 err => this.notifier.error(err.message) 130 err => this.notifier.error(err.message)
126 ) 131 )
127 } 132 }
133
134 private toHtml (text: string) {
135 return this.markdownRenderer.textMarkdownToHTML(text)
136 }
128} 137}
diff --git a/client/src/app/+admin/moderation/video-auto-blacklist-list/index.ts b/client/src/app/+admin/moderation/video-auto-blacklist-list/index.ts
new file mode 100644
index 000000000..e3522f68c
--- /dev/null
+++ b/client/src/app/+admin/moderation/video-auto-blacklist-list/index.ts
@@ -0,0 +1 @@
export * from './video-auto-blacklist-list.component'
diff --git a/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.html b/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.html
new file mode 100644
index 000000000..62dde60bb
--- /dev/null
+++ b/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.html
@@ -0,0 +1,19 @@
1<my-videos-selection
2 [(selection)]="selection"
3 [(videosModel)]="videos"
4 [miniatureDisplayOptions]="miniatureDisplayOptions"
5 [titlePage]="titlePage"
6 [getVideosObservableFunction]="getVideosObservableFunction"
7>
8 <ng-template ptTemplate="globalButtons">
9 <span class="action-button action-button-unblacklist-selection" (click)="removeSelectedVideosFromBlacklist()">
10 <my-global-icon iconName="tick"></my-global-icon>
11 <ng-container i18n>Unblacklist</ng-container>
12 </span>
13 </ng-template>
14
15 <ng-template ptTemplate="rowButtons" let-video>
16 <my-button i18n-label label="Unblacklist" icon="tick" (click)="removeVideoFromBlacklist(video)"></my-button>
17 </ng-template>
18
19</my-videos-selection>
diff --git a/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.scss b/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.scss
new file mode 100644
index 000000000..85ebc6041
--- /dev/null
+++ b/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.scss
@@ -0,0 +1,14 @@
1@import '_variables';
2@import '_mixins';
3
4.action-button-unblacklist-selection {
5 display: inline-block;
6
7 @include peertube-button;
8 @include orange-button;
9 @include button-with-icon(21px);
10
11 my-global-icon {
12 @include apply-svg-color(#fff);
13 }
14}
diff --git a/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.ts b/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.ts
new file mode 100644
index 000000000..fb2962b47
--- /dev/null
+++ b/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.ts
@@ -0,0 +1,86 @@
1import { Component } from '@angular/core'
2import { I18n } from '@ngx-translate/i18n-polyfill'
3import { ActivatedRoute, Router } from '@angular/router'
4import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
5import { AuthService, Notifier, ServerService } from '@app/core'
6import { VideoBlacklistService } from '@app/shared'
7import { immutableAssign } from '@app/shared/misc/utils'
8import { ScreenService } from '@app/shared/misc/screen.service'
9import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component'
10import { SelectionType } from '@app/shared/video/videos-selection.component'
11import { Video } from '@app/shared/video/video.model'
12
13@Component({
14 selector: 'my-video-auto-blacklist-list',
15 templateUrl: './video-auto-blacklist-list.component.html',
16 styleUrls: [ './video-auto-blacklist-list.component.scss' ]
17})
18export class VideoAutoBlacklistListComponent {
19 titlePage: string
20 selection: SelectionType = {}
21 miniatureDisplayOptions: MiniatureDisplayOptions = {
22 date: true,
23 views: false,
24 by: true,
25 privacyLabel: false,
26 privacyText: true,
27 state: false,
28 blacklistInfo: false,
29 nsfw: true
30 }
31 pagination: ComponentPagination = {
32 currentPage: 1,
33 itemsPerPage: 5,
34 totalItems: null
35 }
36 videos: Video[] = []
37 getVideosObservableFunction = this.getVideosObservable.bind(this)
38
39 constructor (
40 protected router: Router,
41 protected route: ActivatedRoute,
42 protected notifier: Notifier,
43 protected authService: AuthService,
44 protected screenService: ScreenService,
45 protected serverService: ServerService,
46 private i18n: I18n,
47 private videoBlacklistService: VideoBlacklistService
48 ) {
49 this.titlePage = this.i18n('Auto-blacklisted videos')
50 }
51
52 getVideosObservable (page: number) {
53 const newPagination = immutableAssign(this.pagination, { currentPage: page })
54
55 return this.videoBlacklistService.getAutoBlacklistedAsVideoList(newPagination)
56 }
57
58 removeVideoFromBlacklist (entry: Video) {
59 this.videoBlacklistService.removeVideoFromBlacklist(entry.id).subscribe(
60 () => {
61 this.notifier.success(this.i18n('Video {{name}} removed from blacklist.', { name: entry.name }))
62
63 this.videos = this.videos.filter(v => v.id !== entry.id)
64 },
65
66 error => this.notifier.error(error.message)
67 )
68 }
69
70 removeSelectedVideosFromBlacklist () {
71 const toReleaseVideosIds = Object.keys(this.selection)
72 .filter(k => this.selection[ k ] === true)
73 .map(k => parseInt(k, 10))
74
75 this.videoBlacklistService.removeVideoFromBlacklist(toReleaseVideosIds).subscribe(
76 () => {
77 this.notifier.success(this.i18n('{{num}} videos removed from blacklist.', { num: toReleaseVideosIds.length }))
78
79 this.selection = {}
80 this.videos = this.videos.filter(v => toReleaseVideosIds.includes(v.id) === false)
81 },
82
83 error => this.notifier.error(error.message)
84 )
85 }
86}
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 247f441c1..608dff2d8 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
@@ -41,7 +41,7 @@
41 <tr> 41 <tr>
42 <td class="moderation-expanded" colspan="6"> 42 <td class="moderation-expanded" colspan="6">
43 <span i18n class="moderation-expanded-label">Blacklist reason:</span> 43 <span i18n class="moderation-expanded-label">Blacklist reason:</span>
44 <span class="moderation-expanded-text" [innerHTML]="toHtml(videoBlacklist.reason)"></span> 44 <span class="moderation-expanded-text" [innerHTML]="videoBlacklist.reasonHtml"></span>
45 </td> 45 </td>
46 </tr> 46 </tr>
47 </ng-template> 47 </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 b27bbbfef..f4bce7c48 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,9 +1,9 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { SortMeta } from 'primeng/components/common/sortmeta' 2import { SortMeta } from 'primeng/components/common/sortmeta'
3import { Notifier } 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'
6import { VideoBlacklist } from '../../../../../../shared' 6import { VideoBlacklist, VideoBlacklistType } 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 { Video } from '../../../shared/video/video.model' 9import { Video } from '../../../shared/video/video.model'
@@ -15,16 +15,18 @@ import { MarkdownService } from '@app/shared/renderer'
15 styleUrls: [ '../moderation.component.scss' ] 15 styleUrls: [ '../moderation.component.scss' ]
16}) 16})
17export class VideoBlacklistListComponent extends RestTable implements OnInit { 17export class VideoBlacklistListComponent extends RestTable implements OnInit {
18 blacklist: VideoBlacklist[] = [] 18 blacklist: (VideoBlacklist & { reasonHtml?: string })[] = []
19 totalRecords = 0 19 totalRecords = 0
20 rowsPerPage = 10 20 rowsPerPage = 10
21 sort: SortMeta = { field: 'createdAt', order: 1 } 21 sort: SortMeta = { field: 'createdAt', order: 1 }
22 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 22 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
23 listBlacklistTypeFilter: VideoBlacklistType = undefined
23 24
24 videoBlacklistActions: DropdownAction<VideoBlacklist>[] = [] 25 videoBlacklistActions: DropdownAction<VideoBlacklist>[] = []
25 26
26 constructor ( 27 constructor (
27 private notifier: Notifier, 28 private notifier: Notifier,
29 private serverService: ServerService,
28 private confirmService: ConfirmService, 30 private confirmService: ConfirmService,
29 private videoBlacklistService: VideoBlacklistService, 31 private videoBlacklistService: VideoBlacklistService,
30 private markdownRenderer: MarkdownService, 32 private markdownRenderer: MarkdownService,
@@ -32,6 +34,11 @@ export class VideoBlacklistListComponent extends RestTable implements OnInit {
32 ) { 34 ) {
33 super() 35 super()
34 36
37 // don't filter if auto-blacklist not enabled as this will be only list
38 if (this.serverService.getConfig().autoBlacklist.videos.ofUsers.enabled) {
39 this.listBlacklistTypeFilter = VideoBlacklistType.MANUAL
40 }
41
35 this.videoBlacklistActions = [ 42 this.videoBlacklistActions = [
36 { 43 {
37 label: this.i18n('Unblacklist'), 44 label: this.i18n('Unblacklist'),
@@ -77,11 +84,16 @@ export class VideoBlacklistListComponent extends RestTable implements OnInit {
77 } 84 }
78 85
79 protected loadData () { 86 protected loadData () {
80 this.videoBlacklistService.listBlacklist(this.pagination, this.sort) 87 this.videoBlacklistService.listBlacklist(this.pagination, this.sort, this.listBlacklistTypeFilter)
81 .subscribe( 88 .subscribe(
82 resultList => { 89 async resultList => {
83 this.blacklist = resultList.data
84 this.totalRecords = resultList.total 90 this.totalRecords = resultList.total
91
92 this.blacklist = resultList.data
93
94 for (const element of this.blacklist) {
95 Object.assign(element, { reasonHtml: await this.toHtml(element.reason) })
96 }
85 }, 97 },
86 98
87 err => this.notifier.error(err.message) 99 err => this.notifier.error(err.message)
diff --git a/client/src/app/+admin/system/debug/debug.component.html b/client/src/app/+admin/system/debug/debug.component.html
new file mode 100644
index 000000000..f35414b37
--- /dev/null
+++ b/client/src/app/+admin/system/debug/debug.component.html
@@ -0,0 +1,19 @@
1<div class="root">
2 <h4>IP</h4>
3
4 <p>PeerTube thinks your public IP is <strong>{{ debug?.ip }}</strong>.</p>
5
6 <p>If this is not your correct public IP, please consider fixing it because:</p>
7 <ul>
8 <li>Views may not be counted correctly (reduced compared to what they should be)</li>
9 <li>Anti brute force system could be overzealous</li>
10 <li>P2P system could not work correctly</li>
11 </ul>
12
13 <p>To fix it:<p>
14 <ul>
15 <li>Check the <code>trust_proxy</code> configuration key</li>
16 <li>If you run PeerTube using Docker, check you run the <code>reverse-proxy</code> with <code>network_mode: "host"</code>
17 (see <a href="https://github.com/Chocobozzz/PeerTube/issues/1643#issuecomment-464789666">issue 1643</a>)</li>
18 </ul>
19</div>
diff --git a/client/src/app/+admin/system/debug/debug.component.scss b/client/src/app/+admin/system/debug/debug.component.scss
new file mode 100644
index 000000000..90addd284
--- /dev/null
+++ b/client/src/app/+admin/system/debug/debug.component.scss
@@ -0,0 +1,6 @@
1@import '_variables';
2@import '_mixins';
3
4.root {
5 font-size: 14px;
6}
diff --git a/client/src/app/+admin/system/debug/debug.component.ts b/client/src/app/+admin/system/debug/debug.component.ts
new file mode 100644
index 000000000..8a77f79f7
--- /dev/null
+++ b/client/src/app/+admin/system/debug/debug.component.ts
@@ -0,0 +1,31 @@
1import { Component, OnInit } from '@angular/core'
2import { Notifier } from '@app/core'
3import { Debug } from '@shared/models/server'
4import { DebugService } from '@app/+admin/system/debug/debug.service'
5
6@Component({
7 templateUrl: './debug.component.html',
8 styleUrls: [ './debug.component.scss' ]
9})
10export class DebugComponent implements OnInit {
11 debug: Debug
12
13 constructor (
14 private debugService: DebugService,
15 private notifier: Notifier
16 ) {
17 }
18
19 ngOnInit (): void {
20 this.load()
21 }
22
23 load () {
24 this.debugService.getDebug()
25 .subscribe(
26 debug => this.debug = debug,
27
28 err => this.notifier.error(err.message)
29 )
30 }
31}
diff --git a/client/src/app/+admin/system/debug/debug.service.ts b/client/src/app/+admin/system/debug/debug.service.ts
new file mode 100644
index 000000000..6c722d177
--- /dev/null
+++ b/client/src/app/+admin/system/debug/debug.service.ts
@@ -0,0 +1,25 @@
1import { catchError } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { Observable } from 'rxjs'
5import { environment } from '../../../../environments/environment'
6import { RestExtractor, RestService } from '../../../shared'
7import { Debug } from '@shared/models/server'
8
9@Injectable()
10export class DebugService {
11 private static BASE_DEBUG_URL = environment.apiUrl + '/api/v1/server/debug'
12
13 constructor (
14 private authHttp: HttpClient,
15 private restService: RestService,
16 private restExtractor: RestExtractor
17 ) {}
18
19 getDebug (): Observable<Debug> {
20 return this.authHttp.get<Debug>(DebugService.BASE_DEBUG_URL)
21 .pipe(
22 catchError(err => this.restExtractor.handleError(err))
23 )
24 }
25}
diff --git a/client/src/app/+admin/system/debug/index.ts b/client/src/app/+admin/system/debug/index.ts
new file mode 100644
index 000000000..7fc7a0721
--- /dev/null
+++ b/client/src/app/+admin/system/debug/index.ts
@@ -0,0 +1,2 @@
1export * from './debug.component'
2export * from './debug.service'
diff --git a/client/src/app/+admin/system/index.ts b/client/src/app/+admin/system/index.ts
new file mode 100644
index 000000000..226d999d2
--- /dev/null
+++ b/client/src/app/+admin/system/index.ts
@@ -0,0 +1,4 @@
1export * from './jobs'
2export * from './logs'
3export * from './system.component'
4export * from './system.routes'
diff --git a/client/src/app/+admin/system/jobs/index.ts b/client/src/app/+admin/system/jobs/index.ts
new file mode 100644
index 000000000..486a745e4
--- /dev/null
+++ b/client/src/app/+admin/system/jobs/index.ts
@@ -0,0 +1,2 @@
1export * from './job.service'
2export * from './jobs.component'
diff --git a/client/src/app/+admin/jobs/shared/job.service.ts b/client/src/app/+admin/system/jobs/job.service.ts
index b96dc3359..b96dc3359 100644
--- a/client/src/app/+admin/jobs/shared/job.service.ts
+++ b/client/src/app/+admin/system/jobs/job.service.ts
diff --git a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.html b/client/src/app/+admin/system/jobs/jobs.component.html
index 7ed1888e2..7ed1888e2 100644
--- a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.html
+++ b/client/src/app/+admin/system/jobs/jobs.component.html
diff --git a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.scss b/client/src/app/+admin/system/jobs/jobs.component.scss
index ab05f1982..ab05f1982 100644
--- a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.scss
+++ b/client/src/app/+admin/system/jobs/jobs.component.scss
diff --git a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.ts b/client/src/app/+admin/system/jobs/jobs.component.ts
index b265e1dd6..ebfb52779 100644
--- a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.ts
+++ b/client/src/app/+admin/system/jobs/jobs.component.ts
@@ -5,15 +5,15 @@ import { SortMeta } from 'primeng/primeng'
5import { Job } from '../../../../../../shared/index' 5import { Job } from '../../../../../../shared/index'
6import { JobState } from '../../../../../../shared/models' 6import { JobState } from '../../../../../../shared/models'
7import { RestPagination, RestTable } from '../../../shared' 7import { RestPagination, RestTable } from '../../../shared'
8import { JobService } from '../shared' 8import { JobService } from './job.service'
9import { I18n } from '@ngx-translate/i18n-polyfill' 9import { I18n } from '@ngx-translate/i18n-polyfill'
10 10
11@Component({ 11@Component({
12 selector: 'my-jobs-list', 12 selector: 'my-jobs',
13 templateUrl: './jobs-list.component.html', 13 templateUrl: './jobs.component.html',
14 styleUrls: [ './jobs-list.component.scss' ] 14 styleUrls: [ './jobs.component.scss' ]
15}) 15})
16export class JobsListComponent extends RestTable implements OnInit { 16export class JobsComponent extends RestTable implements OnInit {
17 private static JOB_STATE_LOCAL_STORAGE_STATE = 'jobs-list-state' 17 private static JOB_STATE_LOCAL_STORAGE_STATE = 'jobs-list-state'
18 18
19 jobState: JobState = 'waiting' 19 jobState: JobState = 'waiting'
@@ -58,12 +58,12 @@ export class JobsListComponent extends RestTable implements OnInit {
58 } 58 }
59 59
60 private loadJobState () { 60 private loadJobState () {
61 const result = peertubeLocalStorage.getItem(JobsListComponent.JOB_STATE_LOCAL_STORAGE_STATE) 61 const result = peertubeLocalStorage.getItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_STATE)
62 62
63 if (result) this.jobState = result as JobState 63 if (result) this.jobState = result as JobState
64 } 64 }
65 65
66 private saveJobState () { 66 private saveJobState () {
67 peertubeLocalStorage.setItem(JobsListComponent.JOB_STATE_LOCAL_STORAGE_STATE, this.jobState) 67 peertubeLocalStorage.setItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_STATE, this.jobState)
68 } 68 }
69} 69}
diff --git a/client/src/app/+admin/system/logs/index.ts b/client/src/app/+admin/system/logs/index.ts
new file mode 100644
index 000000000..7b56d4237
--- /dev/null
+++ b/client/src/app/+admin/system/logs/index.ts
@@ -0,0 +1,2 @@
1export * from './logs.component'
2export * from './logs.service'
diff --git a/client/src/app/+admin/system/logs/log-row.model.ts b/client/src/app/+admin/system/logs/log-row.model.ts
new file mode 100644
index 000000000..9bc7dafdd
--- /dev/null
+++ b/client/src/app/+admin/system/logs/log-row.model.ts
@@ -0,0 +1,21 @@
1import { LogLevel } from '@shared/models/server/log-level.type'
2import omit from 'lodash-es/omit'
3
4export class LogRow {
5 date: Date
6 localeDate: string
7 level: LogLevel
8 message: string
9 meta: string
10
11 constructor (row: any) {
12 this.date = new Date(row.timestamp)
13 this.localeDate = this.date.toLocaleString()
14 this.level = row.level
15 this.message = row.message
16
17 const metaObj = omit(row, 'timestamp', 'level', 'message', 'label')
18
19 if (Object.keys(metaObj).length !== 0) this.meta = JSON.stringify(metaObj, undefined, 2)
20 }
21}
diff --git a/client/src/app/+admin/system/logs/logs.component.html b/client/src/app/+admin/system/logs/logs.component.html
new file mode 100644
index 000000000..45723a655
--- /dev/null
+++ b/client/src/app/+admin/system/logs/logs.component.html
@@ -0,0 +1,31 @@
1<div class="header">
2 <div class="peertube-select-container">
3 <select [(ngModel)]="startDate" (ngModelChange)="refresh()">
4 <option *ngFor="let timeChoice of timeChoices" [value]="timeChoice.id">{{ timeChoice.label }}</option>
5 </select>
6 </div>
7
8 <div class="peertube-select-container">
9 <select [(ngModel)]="level" (ngModelChange)="refresh()">
10 <option *ngFor="let levelChoice of levelChoices" [value]="levelChoice.id">{{ levelChoice.label }}</option>
11 </select>
12 </div>
13
14 <my-button i18n-label label="Refresh" icon="refresh" (click)="refresh()"></my-button>
15</div>
16
17<div class="logs">
18 <div *ngIf="loading">Loading...</div>
19
20 <div #logsElement>
21 <div *ngFor="let log of logs" class="log-row" [ngClass]="{ error: log.level === 'error', warn: log.level === 'warn' }">
22 <span class="log-level">{{ log.level }}</span>
23
24 <span class="log-date">[{{ log.localeDate }}]</span>
25
26 {{ log.message }}
27
28 {{ log.meta }}
29 </div>
30 </div>
31</div>
diff --git a/client/src/app/+admin/system/logs/logs.component.scss b/client/src/app/+admin/system/logs/logs.component.scss
new file mode 100644
index 000000000..7ad2e853c
--- /dev/null
+++ b/client/src/app/+admin/system/logs/logs.component.scss
@@ -0,0 +1,49 @@
1@import '_variables';
2@import '_mixins';
3
4.logs {
5 font-family: monospace;
6 font-size: 13px;
7 max-height: 500px;
8 overflow-y: auto;
9 background: rgba(0, 0, 0, 0.03);
10 padding: 20px;
11
12 .log-row {
13 margin-top: 1px;
14 word-break: break-word;
15
16 &:hover {
17 background: rgba(0, 0, 0, 0.07);
18 }
19 }
20
21 .log-level {
22 font-weight: $font-semibold;
23 margin-right: 5px;
24 }
25
26 .warn {
27 color: $orange-color;
28 }
29
30 .error {
31 color: $red;
32 }
33}
34
35.header {
36 display: flex;
37 justify-content: flex-end;
38 margin-bottom: 10px;
39
40 .peertube-select-container {
41 @include peertube-select-container(150px);
42 }
43
44 my-button,
45 .peertube-select-container {
46 margin-left: 10px;
47 }
48}
49
diff --git a/client/src/app/+admin/system/logs/logs.component.ts b/client/src/app/+admin/system/logs/logs.component.ts
new file mode 100644
index 000000000..1a3508a3b
--- /dev/null
+++ b/client/src/app/+admin/system/logs/logs.component.ts
@@ -0,0 +1,111 @@
1import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
2import { LogsService } from '@app/+admin/system/logs/logs.service'
3import { Notifier } from '@app/core'
4import { LogRow } from '@app/+admin/system/logs/log-row.model'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { LogLevel } from '@shared/models/server/log-level.type'
7
8@Component({
9 templateUrl: './logs.component.html',
10 styleUrls: [ './logs.component.scss' ]
11})
12export class LogsComponent implements OnInit {
13 @ViewChild('logsElement') logsElement: ElementRef<HTMLElement>
14
15 loading = false
16
17 logs: LogRow[] = []
18 timeChoices: { id: string, label: string }[] = []
19 levelChoices: { id: LogLevel, label: string }[] = []
20
21 startDate: string
22 level: LogLevel
23
24 constructor (
25 private logsService: LogsService,
26 private notifier: Notifier,
27 private i18n: I18n
28 ) { }
29
30 ngOnInit (): void {
31 this.buildTimeChoices()
32 this.buildLevelChoices()
33
34 this.load()
35 }
36
37 refresh () {
38 this.logs = []
39 this.load()
40 }
41
42 load () {
43 this.loading = true
44
45 this.logsService.getLogs(this.level, this.startDate)
46 .subscribe(
47 logs => {
48 this.logs = logs
49
50 setTimeout(() => {
51 this.logsElement.nativeElement.scrollIntoView({ block: 'end', inline: 'nearest' })
52 })
53 },
54
55 err => this.notifier.error(err.message),
56
57 () => this.loading = false
58 )
59 }
60
61 buildTimeChoices () {
62 const lastHour = new Date()
63 lastHour.setHours(lastHour.getHours() - 1)
64
65 const lastDay = new Date()
66 lastDay.setDate(lastDay.getDate() - 1)
67
68 const lastWeek = new Date()
69 lastWeek.setDate(lastWeek.getDate() - 7)
70
71 this.timeChoices = [
72 {
73 id: lastWeek.toISOString(),
74 label: this.i18n('Last week')
75 },
76 {
77 id: lastDay.toISOString(),
78 label: this.i18n('Last day')
79 },
80 {
81 id: lastHour.toISOString(),
82 label: this.i18n('Last hour')
83 }
84 ]
85
86 this.startDate = lastHour.toISOString()
87 }
88
89 buildLevelChoices () {
90 this.levelChoices = [
91 {
92 id: 'debug',
93 label: this.i18n('Debug')
94 },
95 {
96 id: 'info',
97 label: this.i18n('Info')
98 },
99 {
100 id: 'warn',
101 label: this.i18n('Warning')
102 },
103 {
104 id: 'error',
105 label: this.i18n('Error')
106 }
107 ]
108
109 this.level = 'warn'
110 }
111}
diff --git a/client/src/app/+admin/system/logs/logs.service.ts b/client/src/app/+admin/system/logs/logs.service.ts
new file mode 100644
index 000000000..24b9cb6d1
--- /dev/null
+++ b/client/src/app/+admin/system/logs/logs.service.ts
@@ -0,0 +1,33 @@
1import { catchError, map } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { Observable } from 'rxjs'
5import { environment } from '../../../../environments/environment'
6import { RestExtractor, RestService } from '../../../shared'
7import { LogRow } from '@app/+admin/system/logs/log-row.model'
8import { LogLevel } from '@shared/models/server/log-level.type'
9
10@Injectable()
11export class LogsService {
12 private static BASE_LOG_URL = environment.apiUrl + '/api/v1/server/logs'
13
14 constructor (
15 private authHttp: HttpClient,
16 private restService: RestService,
17 private restExtractor: RestExtractor
18 ) {}
19
20 getLogs (level: LogLevel, startDate: string, endDate?: string): Observable<any[]> {
21 let params = new HttpParams()
22 params = params.append('startDate', startDate)
23 params = params.append('level', level)
24
25 if (endDate) params.append('endDate', endDate)
26
27 return this.authHttp.get<any[]>(LogsService.BASE_LOG_URL, { params })
28 .pipe(
29 map(rows => rows.map(r => new LogRow(r))),
30 catchError(err => this.restExtractor.handleError(err))
31 )
32 }
33}
diff --git a/client/src/app/+admin/system/system.component.html b/client/src/app/+admin/system/system.component.html
new file mode 100644
index 000000000..7c4278d35
--- /dev/null
+++ b/client/src/app/+admin/system/system.component.html
@@ -0,0 +1,13 @@
1<div class="admin-sub-header">
2 <div i18n class="form-sub-title">System</div>
3
4 <div class="admin-sub-nav">
5 <a *ngIf="hasJobsRight()" i18n routerLink="jobs" routerLinkActive="active">Jobs</a>
6
7 <a *ngIf="hasLogsRight()" i18n routerLink="logs" routerLinkActive="active">Logs</a>
8
9 <a *ngIf="hasDebugRight()" i18n routerLink="debug" routerLinkActive="active">Debug</a>
10 </div>
11</div>
12
13<router-outlet></router-outlet>
diff --git a/client/src/app/+admin/system/system.component.scss b/client/src/app/+admin/system/system.component.scss
new file mode 100644
index 000000000..766d7853b
--- /dev/null
+++ b/client/src/app/+admin/system/system.component.scss
@@ -0,0 +1,4 @@
1.form-sub-title {
2 flex-grow: 0;
3 margin-right: 30px;
4}
diff --git a/client/src/app/+admin/system/system.component.ts b/client/src/app/+admin/system/system.component.ts
new file mode 100644
index 000000000..b544c2a97
--- /dev/null
+++ b/client/src/app/+admin/system/system.component.ts
@@ -0,0 +1,24 @@
1import { Component } from '@angular/core'
2import { UserRight } from '@shared/models'
3import { AuthService } from '@app/core'
4
5@Component({
6 templateUrl: './system.component.html',
7 styleUrls: [ './system.component.scss' ]
8})
9export class SystemComponent {
10
11 constructor (private auth: AuthService) {}
12
13 hasLogsRight () {
14 return this.auth.getUser().hasRight(UserRight.MANAGE_LOGS)
15 }
16
17 hasJobsRight () {
18 return this.auth.getUser().hasRight(UserRight.MANAGE_JOBS)
19 }
20
21 hasDebugRight () {
22 return this.auth.getUser().hasRight(UserRight.MANAGE_DEBUG)
23 }
24}
diff --git a/client/src/app/+admin/system/system.routes.ts b/client/src/app/+admin/system/system.routes.ts
new file mode 100644
index 000000000..2d851794d
--- /dev/null
+++ b/client/src/app/+admin/system/system.routes.ts
@@ -0,0 +1,56 @@
1import { Routes } from '@angular/router'
2import { UserRightGuard } from '../../core'
3import { UserRight } from '../../../../../shared'
4import { JobsComponent } from '@app/+admin/system/jobs/jobs.component'
5import { LogsComponent } from '@app/+admin/system/logs'
6import { SystemComponent } from '@app/+admin/system/system.component'
7import { DebugComponent } from '@app/+admin/system/debug'
8
9export const SystemRoutes: Routes = [
10 {
11 path: 'system',
12 component: SystemComponent,
13 data: {
14 },
15 children: [
16 {
17 path: '',
18 redirectTo: 'jobs',
19 pathMatch: 'full'
20 },
21 {
22 path: 'jobs',
23 canActivate: [ UserRightGuard ],
24 component: JobsComponent,
25 data: {
26 meta: {
27 userRight: UserRight.MANAGE_JOBS,
28 title: 'Jobs'
29 }
30 }
31 },
32 {
33 path: 'logs',
34 canActivate: [ UserRightGuard ],
35 component: LogsComponent,
36 data: {
37 meta: {
38 userRight: UserRight.MANAGE_LOGS,
39 title: 'Logs'
40 }
41 }
42 },
43 {
44 path: 'debug',
45 canActivate: [ UserRightGuard ],
46 component: DebugComponent,
47 data: {
48 meta: {
49 userRight: UserRight.MANAGE_DEBUG,
50 title: 'Debug'
51 }
52 }
53 }
54 ]
55 }
56]
diff --git a/client/src/app/+admin/users/user-edit/index.ts b/client/src/app/+admin/users/user-edit/index.ts
index fd80a02e0..ec734ef92 100644
--- a/client/src/app/+admin/users/user-edit/index.ts
+++ b/client/src/app/+admin/users/user-edit/index.ts
@@ -1,2 +1,3 @@
1export * from './user-create.component' 1export * from './user-create.component'
2export * from './user-update.component' 2export * from './user-update.component'
3export * from './user-password.component'
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 137ecfcbd..9a6801806 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
@@ -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 { UserAdminFlag } from '@shared/models/users/user-flag.model'
11 12
12@Component({ 13@Component({
13 selector: 'my-user-create', 14 selector: 'my-user-create',
@@ -45,7 +46,8 @@ export class UserCreateComponent extends UserEdit implements OnInit {
45 password: this.userValidatorsService.USER_PASSWORD, 46 password: this.userValidatorsService.USER_PASSWORD,
46 role: this.userValidatorsService.USER_ROLE, 47 role: this.userValidatorsService.USER_ROLE,
47 videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA, 48 videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA,
48 videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY 49 videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY,
50 byPassAutoBlacklist: null
49 }, defaultValues) 51 }, defaultValues)
50 } 52 }
51 53
@@ -54,8 +56,11 @@ export class UserCreateComponent extends UserEdit implements OnInit {
54 56
55 const userCreate: UserCreate = this.form.value 57 const userCreate: UserCreate = this.form.value
56 58
59 userCreate.adminFlags = this.buildAdminFlags(this.form.value)
60
57 // A select in HTML is always mapped as a string, we convert it to number 61 // A select in HTML is always mapped as a string, we convert it to number
58 userCreate.videoQuota = parseInt(this.form.value['videoQuota'], 10) 62 userCreate.videoQuota = parseInt(this.form.value['videoQuota'], 10)
63 userCreate.videoQuotaDaily = parseInt(this.form.value['videoQuotaDaily'], 10)
59 64
60 this.userService.addUser(userCreate).subscribe( 65 this.userService.addUser(userCreate).subscribe(
61 () => { 66 () => {
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 56cf7d17d..400bac5d4 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
@@ -79,5 +79,26 @@
79 </div> 79 </div>
80 </div> 80 </div>
81 81
82 <div class="form-group">
83 <my-peertube-checkbox
84 inputName="byPassAutoBlacklist" formControlName="byPassAutoBlacklist"
85 i18n-labelText labelText="Bypass video auto blacklist"
86 ></my-peertube-checkbox>
87 </div>
88
82 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> 89 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
83</form> 90</form>
91
92<div *ngIf="!isCreation()" class="danger-zone">
93 <div class="account-title" i18n>Danger Zone</div>
94
95 <div class="form-group reset-password-email">
96 <label i18n>Send a link to reset the password by email to the user</label>
97 <button (click)="resetPassword()" i18n>Ask for new password</button>
98 </div>
99
100 <div class="form-group">
101 <label i18n>Manually set the user password</label>
102 <my-user-password [userId]="userId"></my-user-password>
103 </div>
104</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 6675f65cc..c1cc4ca45 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
@@ -14,7 +14,7 @@ input:not([type=submit]) {
14 @include peertube-select-container(340px); 14 @include peertube-select-container(340px);
15} 15}
16 16
17input[type=submit] { 17input[type=submit], button {
18 @include peertube-button; 18 @include peertube-button;
19 @include orange-button; 19 @include orange-button;
20 20
@@ -25,3 +25,23 @@ input[type=submit] {
25 margin-top: 5px; 25 margin-top: 5px;
26 font-size: 11px; 26 font-size: 11px;
27} 27}
28
29.account-title {
30 @include in-content-small-title;
31
32 margin-top: 55px;
33 margin-bottom: 30px;
34}
35
36.danger-zone {
37 .reset-password-email {
38 margin-bottom: 30px;
39 padding-bottom: 30px;
40 border-bottom: 1px solid rgba(0, 0, 0, 0.1);
41
42 button {
43 display: block;
44 margin-top: 0;
45 }
46 }
47}
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 0b3511e8e..adce1b2d4 100644
--- a/client/src/app/+admin/users/user-edit/user-edit.ts
+++ b/client/src/app/+admin/users/user-edit/user-edit.ts
@@ -2,12 +2,14 @@ import { ServerService } from '../../../core'
2import { FormReactive } from '../../../shared' 2import { FormReactive } from '../../../shared'
3import { USER_ROLE_LABELS, VideoResolution } from '../../../../../../shared' 3import { USER_ROLE_LABELS, VideoResolution } from '../../../../../../shared'
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'
5 6
6export abstract class UserEdit extends FormReactive { 7export abstract class UserEdit extends FormReactive {
7 videoQuotaOptions: { value: string, label: string }[] = [] 8 videoQuotaOptions: { value: string, label: string }[] = []
8 videoQuotaDailyOptions: { value: string, label: string }[] = [] 9 videoQuotaDailyOptions: { value: string, label: string }[] = []
9 roles = Object.keys(USER_ROLE_LABELS).map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] })) 10 roles = Object.keys(USER_ROLE_LABELS).map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] }))
10 username: string 11 username: string
12 userId: number
11 13
12 protected abstract serverService: ServerService 14 protected abstract serverService: ServerService
13 protected abstract configService: ConfigService 15 protected abstract configService: ConfigService
@@ -22,7 +24,9 @@ export abstract class UserEdit extends FormReactive {
22 } 24 }
23 25
24 computeQuotaWithTranscoding () { 26 computeQuotaWithTranscoding () {
25 const resolutions = this.serverService.getConfig().transcoding.enabledResolutions 27 const transcodingConfig = this.serverService.getConfig().transcoding
28
29 const resolutions = transcodingConfig.enabledResolutions
26 const higherResolution = VideoResolution.H_1080P 30 const higherResolution = VideoResolution.H_1080P
27 let multiplier = 0 31 let multiplier = 0
28 32
@@ -30,9 +34,19 @@ export abstract class UserEdit extends FormReactive {
30 multiplier += resolution / higherResolution 34 multiplier += resolution / higherResolution
31 } 35 }
32 36
37 if (transcodingConfig.hls.enabled) multiplier *= 2
38
33 return multiplier * parseInt(this.form.value['videoQuota'], 10) 39 return multiplier * parseInt(this.form.value['videoQuota'], 10)
34 } 40 }
35 41
42 resetPassword () {
43 return
44 }
45
46 protected buildAdminFlags (formValue: any) {
47 return formValue.byPassAutoBlacklist ? UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST : UserAdminFlag.NONE
48 }
49
36 protected buildQuotaOptions () { 50 protected buildQuotaOptions () {
37 // These are used by a HTML select, so convert key into strings 51 // These are used by a HTML select, so convert key into strings
38 this.videoQuotaOptions = this.configService 52 this.videoQuotaOptions = this.configService
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
new file mode 100644
index 000000000..a1e1f6216
--- /dev/null
+++ b/client/src/app/+admin/users/user-edit/user-password.component.html
@@ -0,0 +1,21 @@
1<form role="form" (ngSubmit)="formValidated()" [formGroup]="form">
2 <div class="form-group">
3
4 <div class="input-group">
5 <input id="password" [attr.type]="showPassword ? 'text' : 'password'"
6 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
7 >
8 <div class="input-group-append">
9 <button class="btn btn-sm btn-outline-secondary" (click)="togglePasswordVisibility()" type="button">
10 <ng-container *ngIf="!showPassword" i18n>Show</ng-container>
11 <ng-container *ngIf="!!showPassword" i18n>Hide</ng-container>
12 </button>
13 </div>
14 </div>
15 <div *ngIf="formErrors.password" class="form-error">
16 {{ formErrors.password }}
17 </div>
18 </div>
19
20 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
21</form>
diff --git a/client/src/app/+admin/users/user-edit/user-password.component.scss b/client/src/app/+admin/users/user-edit/user-password.component.scss
new file mode 100644
index 000000000..217d585af
--- /dev/null
+++ b/client/src/app/+admin/users/user-edit/user-password.component.scss
@@ -0,0 +1,22 @@
1@import '_variables';
2@import '_mixins';
3
4input:not([type=submit]):not([type=checkbox]) {
5 @include peertube-input-text(340px);
6
7 display: block;
8 border-top-right-radius: 0;
9 border-bottom-right-radius: 0;
10 border-right: none;
11}
12
13input[type=submit] {
14 @include peertube-button;
15 @include orange-button;
16
17 margin-top: 10px;
18}
19
20.input-group-append {
21 height: 30px;
22}
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
new file mode 100644
index 000000000..5b3040440
--- /dev/null
+++ b/client/src/app/+admin/users/user-edit/user-password.component.ts
@@ -0,0 +1,64 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router'
3import { UserService } from '@app/shared/users/user.service'
4import { Notifier } from '../../../core'
5import { User, UserUpdate } from '../../../../../../shared'
6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
8import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
9import { FormReactive } from '../../../shared'
10
11@Component({
12 selector: 'my-user-password',
13 templateUrl: './user-password.component.html',
14 styleUrls: [ './user-password.component.scss' ]
15})
16export class UserPasswordComponent extends FormReactive implements OnInit {
17 error: string
18 username: string
19 showPassword = false
20
21 @Input() userId: number
22
23 constructor (
24 protected formValidatorService: FormValidatorService,
25 private userValidatorsService: UserValidatorsService,
26 private route: ActivatedRoute,
27 private router: Router,
28 private notifier: Notifier,
29 private userService: UserService,
30 private i18n: I18n
31 ) {
32 super()
33 }
34
35 ngOnInit () {
36 this.buildForm({
37 password: this.userValidatorsService.USER_PASSWORD
38 })
39 }
40
41 formValidated () {
42 this.error = undefined
43
44 const userUpdate: UserUpdate = this.form.value
45
46 this.userService.updateUser(this.userId, userUpdate).subscribe(
47 () => {
48 this.notifier.success(
49 this.i18n('Password changed for user {{username}}.', { username: this.username })
50 )
51 },
52
53 err => this.error = err.message
54 )
55 }
56
57 togglePasswordVisibility () {
58 this.showPassword = !this.showPassword
59 }
60
61 getFormButtonTitle () {
62 return this.i18n('Update user password')
63 }
64}
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 61e641823..04b2935f4 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
@@ -10,6 +10,7 @@ import { FormValidatorService } from '@app/shared/forms/form-validators/form-val
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'
13 14
14@Component({ 15@Component({
15 selector: 'my-user-update', 16 selector: 'my-user-update',
@@ -19,6 +20,7 @@ import { UserService } from '@app/shared'
19export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { 20export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
20 error: string 21 error: string
21 userId: number 22 userId: number
23 userEmail: string
22 username: string 24 username: string
23 25
24 private paramsSub: Subscription 26 private paramsSub: Subscription
@@ -45,7 +47,8 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
45 email: this.userValidatorsService.USER_EMAIL, 47 email: this.userValidatorsService.USER_EMAIL,
46 role: this.userValidatorsService.USER_ROLE, 48 role: this.userValidatorsService.USER_ROLE,
47 videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA, 49 videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA,
48 videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY 50 videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY,
51 byPassAutoBlacklist: null
49 }, defaultValues) 52 }, defaultValues)
50 53
51 this.paramsSub = this.route.params.subscribe(routeParams => { 54 this.paramsSub = this.route.params.subscribe(routeParams => {
@@ -66,6 +69,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
66 this.error = undefined 69 this.error = undefined
67 70
68 const userUpdate: UserUpdate = this.form.value 71 const userUpdate: UserUpdate = this.form.value
72 userUpdate.adminFlags = this.buildAdminFlags(this.form.value)
69 73
70 // A select in HTML is always mapped as a string, we convert it to number 74 // A select in HTML is always mapped as a string, we convert it to number
71 userUpdate.videoQuota = parseInt(this.form.value['videoQuota'], 10) 75 userUpdate.videoQuota = parseInt(this.form.value['videoQuota'], 10)
@@ -89,15 +93,29 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
89 return this.i18n('Update user') 93 return this.i18n('Update user')
90 } 94 }
91 95
96 resetPassword () {
97 this.userService.askResetPassword(this.userEmail).subscribe(
98 () => {
99 this.notifier.success(
100 this.i18n('An email asking for password reset has been sent to {{username}}.', { username: this.username })
101 )
102 },
103
104 err => this.error = err.message
105 )
106 }
107
92 private onUserFetched (userJson: User) { 108 private onUserFetched (userJson: User) {
93 this.userId = userJson.id 109 this.userId = userJson.id
94 this.username = userJson.username 110 this.username = userJson.username
111 this.userEmail = userJson.email
95 112
96 this.form.patchValue({ 113 this.form.patchValue({
97 email: userJson.email, 114 email: userJson.email,
98 role: userJson.role, 115 role: userJson.role,
99 videoQuota: userJson.videoQuota, 116 videoQuota: userJson.videoQuota,
100 videoQuotaDaily: userJson.videoQuotaDaily 117 videoQuotaDaily: userJson.videoQuotaDaily,
118 byPassAutoBlacklist: userJson.adminFlags & UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST
101 }) 119 })
102 } 120 }
103} 121}
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 d42af37d4..6e274f689 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
@@ -4,24 +4,19 @@
4 <label i18n>History enabled</label> 4 <label i18n>History enabled</label>
5 </div> 5 </div>
6 6
7 <div class="delete-history"> 7 <button class="delete-history" (click)="deleteHistory()" i18n>
8 <button (click)="deleteHistory()" i18n>Delete history</button> 8 <my-global-icon iconName="delete"></my-global-icon>
9 </div> 9 Delete history
10 </button>
10</div> 11</div>
11 12
12 13
13<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 videos history yet.</div>
14 15
15<div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" class="videos" #videosElement> 16<div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" class="videos">
16 <div *ngFor="let videos of videoPages;" class="videos-page"> 17 <div class="video" *ngFor="let video of videos">
17 <div class="video" *ngFor="let video of videos"> 18 <my-video-miniature
18 <my-video-thumbnail [video]="video"></my-video-thumbnail> 19 [video]="video" [displayAsRow]="true"
19 20 (videoRemoved)="removeVideoFromArray(video)" (videoBlacklisted)="removeVideoFromArray(video)"></my-video-miniature>
20 <div class="video-info">
21 <a tabindex="-1" class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a>
22 <span i18n class="video-info-date-views">{{ video.views | myNumberFormatter }} views</span>
23 <a tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', video.byAccount ]">{{ video.byAccount }}</a>
24 </div>
25 </div>
26 </div> 21 </div>
27</div> 22</div>
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 e7c6863f1..af6395fb1 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
@@ -23,77 +23,18 @@
23 } 23 }
24 24
25 .delete-history { 25 .delete-history {
26 font-size: 15px; 26 @include peertube-button;
27 @include grey-button;
28 @include button-with-icon;
27 29
28 button { 30 font-size: 15px;
29 @include peertube-button;
30 @include grey-button;
31 }
32 } 31 }
33} 32}
34 33
35.video { 34.video {
36 @include row-blocks; 35 @include row-blocks;
37 36
38 my-video-thumbnail { 37 .my-video-miniature {
39 margin-right: 10px;
40 }
41
42 .video-info {
43 flex-grow: 1; 38 flex-grow: 1;
44
45 .video-info-name {
46 @include disable-default-a-behaviour;
47
48 color: var(--mainForegroundColor);
49 display: block;
50 width: fit-content;
51 font-size: 18px;
52 font-weight: $font-semibold;
53 }
54
55 .video-info-date-views {
56 font-size: 14px;
57 }
58
59 .video-info-account {
60 @include disable-default-a-behaviour;
61
62 display: block;
63 width: fit-content;
64 overflow: hidden;
65 text-overflow: ellipsis;
66 white-space: nowrap;
67 font-size: 14px;
68 color: $grey-foreground-color;
69
70 &:hover {
71 color: $grey-foreground-hover-color;
72 }
73 }
74 }
75}
76
77@media screen and (max-width: $small-view) {
78 .video {
79 flex-direction: column;
80 height: auto;
81 text-align: center;
82
83 .video-info-name {
84 margin: auto;
85 }
86
87 input[type=checkbox] {
88 display: none;
89 }
90
91 my-video-thumbnail {
92 margin-right: 0;
93 }
94
95 .video-buttons {
96 margin-top: 10px;
97 }
98 } 39 }
99} 40}
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 394091bad..73340d21a 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
@@ -1,6 +1,5 @@
1import { Component, OnDestroy, OnInit } from '@angular/core' 1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { Location } from '@angular/common'
4import { immutableAssign } from '@app/shared/misc/utils' 3import { immutableAssign } from '@app/shared/misc/utils'
5import { ComponentPagination } from '@app/shared/rest/component-pagination.model' 4import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
6import { AuthService } from '../../core/auth' 5import { AuthService } from '../../core/auth'
@@ -11,7 +10,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
11import { ScreenService } from '@app/shared/misc/screen.service' 10import { ScreenService } from '@app/shared/misc/screen.service'
12import { UserHistoryService } from '@app/shared/users/user-history.service' 11import { UserHistoryService } from '@app/shared/users/user-history.service'
13import { UserService } from '@app/shared' 12import { UserService } from '@app/shared'
14import { Notifier } from '@app/core' 13import { Notifier, ServerService } from '@app/core'
15 14
16@Component({ 15@Component({
17 selector: 'my-account-history', 16 selector: 'my-account-history',
@@ -20,7 +19,6 @@ import { Notifier } from '@app/core'
20}) 19})
21export class MyAccountHistoryComponent extends AbstractVideoList implements OnInit, OnDestroy { 20export class MyAccountHistoryComponent extends AbstractVideoList implements OnInit, OnDestroy {
22 titlePage: string 21 titlePage: string
23 currentRoute = '/my-account/history/videos'
24 pagination: ComponentPagination = { 22 pagination: ComponentPagination = {
25 currentPage: 1, 23 currentPage: 1,
26 itemsPerPage: 5, 24 itemsPerPage: 5,
@@ -28,16 +26,13 @@ export class MyAccountHistoryComponent extends AbstractVideoList implements OnIn
28 } 26 }
29 videosHistoryEnabled: boolean 27 videosHistoryEnabled: boolean
30 28
31 protected baseVideoWidth = -1
32 protected baseVideoHeight = 155
33
34 constructor ( 29 constructor (
35 protected router: Router, 30 protected router: Router,
31 protected serverService: ServerService,
36 protected route: ActivatedRoute, 32 protected route: ActivatedRoute,
37 protected authService: AuthService, 33 protected authService: AuthService,
38 protected userService: UserService, 34 protected userService: UserService,
39 protected notifier: Notifier, 35 protected notifier: Notifier,
40 protected location: Location,
41 protected screenService: ScreenService, 36 protected screenService: ScreenService,
42 protected i18n: I18n, 37 protected i18n: I18n,
43 private confirmService: ConfirmService, 38 private confirmService: ConfirmService,
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 5709e9f54..c5fd3ccb9 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
@@ -30,8 +30,7 @@
30 </a> 30 </a>
31 </td> 31 </td>
32 <td> 32 <td>
33 <a [href]="videoChangeOwnership.video.url" i18n-title title="Go to the video" target="_blank" 33 <a [href]="videoChangeOwnership.video.url" i18n-title title="Go to the video" target="_blank" rel="noopener noreferrer">
34 rel="noopener noreferrer">
35 {{ videoChangeOwnership.video.name }} 34 {{ videoChangeOwnership.video.name }}
36 </a> 35 </a>
37 </td> 36 </td>
@@ -39,16 +38,12 @@
39 <td i18n>{{ videoChangeOwnership.status }}</td> 38 <td i18n>{{ videoChangeOwnership.status }}</td>
40 <td class="action-cell"> 39 <td class="action-cell">
41 <ng-container *ngIf="videoChangeOwnership.status === 'WAITING'"> 40 <ng-container *ngIf="videoChangeOwnership.status === 'WAITING'">
42 <my-button i18n label="Accept" 41 <my-button i18n-label label="Accept" icon="tick" (click)="openAcceptModal(videoChangeOwnership)"></my-button>
43 icon="tick" 42 <my-button i18n-label label="Refuse" icon="cross" (click)="refuse(videoChangeOwnership)"></my-button>
44 (click)="openAcceptModal(videoChangeOwnership)"></my-button>
45 <my-button i18n label="Refuse"
46 icon="cross"
47 (click)="refuse(videoChangeOwnership)">Refuse</my-button>
48 </ng-container> 43 </ng-container>
49 </td> 44 </td>
50 </tr> 45 </tr>
51 </ng-template> 46 </ng-template>
52</p-table> 47</p-table>
53 48
54<my-account-accept-ownership #myAccountAcceptOwnershipComponent (accepted)="accepted()"></my-account-accept-ownership> \ No newline at end of file 49<my-account-accept-ownership #myAccountAcceptOwnershipComponent (accepted)="accepted()"></my-account-accept-ownership>
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 9996218ca..018d6f996 100644
--- a/client/src/app/+my-account/my-account-routing.module.ts
+++ b/client/src/app/+my-account/my-account-routing.module.ts
@@ -15,6 +15,16 @@ import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blockli
15import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component' 15import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component'
16import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' 16import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
17import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component' 17import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
18import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
19import {
20 MyAccountVideoPlaylistCreateComponent
21} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
22import {
23 MyAccountVideoPlaylistUpdateComponent
24} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
25import {
26 MyAccountVideoPlaylistElementsComponent
27} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
18 28
19const myAccountRoutes: Routes = [ 29const myAccountRoutes: Routes = [
20 { 30 {
@@ -36,6 +46,7 @@ const myAccountRoutes: Routes = [
36 } 46 }
37 } 47 }
38 }, 48 },
49
39 { 50 {
40 path: 'video-channels', 51 path: 'video-channels',
41 component: MyAccountVideoChannelsComponent, 52 component: MyAccountVideoChannelsComponent,
@@ -63,12 +74,54 @@ const myAccountRoutes: Routes = [
63 } 74 }
64 } 75 }
65 }, 76 },
77
78 {
79 path: 'video-playlists',
80 component: MyAccountVideoPlaylistsComponent,
81 data: {
82 meta: {
83 title: 'Account playlists'
84 }
85 }
86 },
87 {
88 path: 'video-playlists/create',
89 component: MyAccountVideoPlaylistCreateComponent,
90 data: {
91 meta: {
92 title: 'Create new playlist'
93 }
94 }
95 },
96 {
97 path: 'video-playlists/:videoPlaylistId',
98 component: MyAccountVideoPlaylistElementsComponent,
99 data: {
100 meta: {
101 title: 'Playlist elements'
102 }
103 }
104 },
105 {
106 path: 'video-playlists/update/:videoPlaylistId',
107 component: MyAccountVideoPlaylistUpdateComponent,
108 data: {
109 meta: {
110 title: 'Update playlist'
111 }
112 }
113 },
114
66 { 115 {
67 path: 'videos', 116 path: 'videos',
68 component: MyAccountVideosComponent, 117 component: MyAccountVideosComponent,
69 data: { 118 data: {
70 meta: { 119 meta: {
71 title: 'Account videos' 120 title: 'Account videos'
121 },
122 reuse: {
123 enabled: true,
124 key: 'my-account-videos-list'
72 } 125 }
73 } 126 }
74 }, 127 },
@@ -123,6 +176,10 @@ const myAccountRoutes: Routes = [
123 data: { 176 data: {
124 meta: { 177 meta: {
125 title: 'Videos history' 178 title: 'Videos history'
179 },
180 reuse: {
181 enabled: true,
182 key: 'my-videos-history-list'
126 } 183 }
127 } 184 }
128 }, 185 },
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html
index 59422d682..93e294a96 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html
@@ -4,8 +4,8 @@
4 <div i18n *ngIf="emailEnabled">Email</div> 4 <div i18n *ngIf="emailEnabled">Email</div>
5</div> 5</div>
6 6
7<div class="custom-row" *ngFor="let notificationType of notificationSettingKeys"> 7<ng-container *ngFor="let notificationType of notificationSettingKeys">
8 <ng-container *ngIf="hasUserRight(notificationType)"> 8 <div class="custom-row" *ngIf="hasUserRight(notificationType)">
9 <div>{{ labelNotifications[notificationType] }}</div> 9 <div>{{ labelNotifications[notificationType] }}</div>
10 10
11 <div> 11 <div>
@@ -15,5 +15,5 @@
15 <div *ngIf="emailEnabled"> 15 <div *ngIf="emailEnabled">
16 <p-inputSwitch [(ngModel)]="emailNotifications[notificationType]" (onChange)="updateEmailSetting(notificationType, $event.checked)"></p-inputSwitch> 16 <p-inputSwitch [(ngModel)]="emailNotifications[notificationType]" (onChange)="updateEmailSetting(notificationType, $event.checked)"></p-inputSwitch>
17 </div> 17 </div>
18 </ng-container> 18 </div>
19</div> 19</ng-container>
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 6feb16ab1..7cd5c3b46 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
@@ -4,7 +4,7 @@
4.custom-row { 4.custom-row {
5 display: flex; 5 display: flex;
6 align-items: center; 6 align-items: center;
7 border-bottom: 1px solid rgba(0, 0, 0, 0.10); 7 border-bottom: 1px solid $separator-border-color;
8 8
9 &:first-child { 9 &:first-child {
10 font-size: 16px; 10 font-size: 16px;
@@ -16,6 +16,14 @@
16 16
17 & > div { 17 & > div {
18 width: 350px; 18 width: 350px;
19
20 @media screen and (max-width: $small-view) {
21 width: auto;
22
23 &:first-child {
24 flex-grow: 1;
25 }
26 }
19 } 27 }
20 28
21 & > div { 29 & > div {
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
index 519bdfab4..34febc457 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
@@ -31,22 +31,27 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
31 private serverService: ServerService, 31 private serverService: ServerService,
32 private notifier: Notifier 32 private notifier: Notifier
33 ) { 33 ) {
34
34 this.labelNotifications = { 35 this.labelNotifications = {
35 newVideoFromSubscription: this.i18n('New video from your subscriptions'), 36 newVideoFromSubscription: this.i18n('New video from your subscriptions'),
36 newCommentOnMyVideo: this.i18n('New comment on your video'), 37 newCommentOnMyVideo: this.i18n('New comment on your video'),
37 videoAbuseAsModerator: this.i18n('New video abuse on local video'), 38 videoAbuseAsModerator: this.i18n('New video abuse'),
39 videoAutoBlacklistAsModerator: this.i18n('Video auto-blacklisted waiting review'),
38 blacklistOnMyVideo: this.i18n('One of your video is blacklisted/unblacklisted'), 40 blacklistOnMyVideo: this.i18n('One of your video is blacklisted/unblacklisted'),
39 myVideoPublished: this.i18n('Video published (after transcoding/scheduled update)'), 41 myVideoPublished: this.i18n('Video published (after transcoding/scheduled update)'),
40 myVideoImportFinished: this.i18n('Video import finished'), 42 myVideoImportFinished: this.i18n('Video import finished'),
41 newUserRegistration: this.i18n('A new user registered on your instance'), 43 newUserRegistration: this.i18n('A new user registered on your instance'),
42 newFollow: this.i18n('You or your channel(s) has a new follower'), 44 newFollow: this.i18n('You or your channel(s) has a new follower'),
43 commentMention: this.i18n('Someone mentioned you in video comments') 45 commentMention: this.i18n('Someone mentioned you in video comments'),
46 newInstanceFollower: this.i18n('Your instance has a new follower')
44 } 47 }
45 this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[] 48 this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[]
46 49
47 this.rightNotifications = { 50 this.rightNotifications = {
48 videoAbuseAsModerator: UserRight.MANAGE_VIDEO_ABUSES, 51 videoAbuseAsModerator: UserRight.MANAGE_VIDEO_ABUSES,
49 newUserRegistration: UserRight.MANAGE_USERS 52 videoAutoBlacklistAsModerator: UserRight.MANAGE_VIDEO_BLACKLIST,
53 newUserRegistration: UserRight.MANAGE_USERS,
54 newInstanceFollower: UserRight.MANAGE_SERVER_FOLLOW
50 } 55 }
51 56
52 this.emailEnabled = this.serverService.getConfig().email.enabled 57 this.emailEnabled = this.serverService.getConfig().email.enabled
diff --git a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts
index 9d2dccdf0..6ce22989b 100644
--- a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts
+++ b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts
@@ -1,7 +1,6 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 3import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { UserSubscriptionService } from '@app/shared/user-subscription' 4import { UserSubscriptionService } from '@app/shared/user-subscription'
6import { ComponentPagination } from '@app/shared/rest/component-pagination.model' 5import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
7 6
@@ -21,8 +20,7 @@ export class MyAccountSubscriptionsComponent implements OnInit {
21 20
22 constructor ( 21 constructor (
23 private userSubscriptionService: UserSubscriptionService, 22 private userSubscriptionService: UserSubscriptionService,
24 private notifier: Notifier, 23 private notifier: Notifier
25 private i18n: I18n
26 ) {} 24 ) {}
27 25
28 ngOnInit () { 26 ngOnInit () {
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 51db2e75d..11e87ba79 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,7 +1,7 @@
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 another video channel</ng-container> 4 <ng-container i18n>Create a new video channel</ng-container>
5 </a> 5 </a>
6</div> 6</div>
7 7
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 da2c5bcd3..3b01b6c9f 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
@@ -35,8 +35,8 @@ export class MyAccountVideoChannelsComponent implements OnInit {
35 async deleteVideoChannel (videoChannel: VideoChannel) { 35 async deleteVideoChannel (videoChannel: VideoChannel) {
36 const res = await this.confirmService.confirmWithInput( 36 const res = await this.confirmService.confirmWithInput(
37 this.i18n( 37 this.i18n(
38 'Do you really want to delete {{channelDisplayName}}? It will delete all videos uploaded in this channel, ' + 38 // tslint:disable
39 'and you will not be able to create another channel with the same name ({{channelName}})!', 39 'Do you really want to delete {{channelDisplayName}}? It will delete all videos uploaded in this channel, and you will not be able to create another channel with the same name ({{channelName}})!',
40 { channelDisplayName: videoChannel.displayName, channelName: videoChannel.name } 40 { channelDisplayName: videoChannel.displayName, channelName: videoChannel.name }
41 ), 41 ),
42 this.i18n( 42 this.i18n(
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts
new file mode 100644
index 000000000..87a10961f
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts
@@ -0,0 +1,93 @@
1import { Component, OnInit } from '@angular/core'
2import { Router } from '@angular/router'
3import { AuthService, Notifier, ServerService } from '@app/core'
4import { MyAccountVideoPlaylistEdit } from './my-account-video-playlist-edit'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7import { VideoPlaylistValidatorsService } from '@app/shared'
8import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
9import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
10import { VideoConstant } from '@shared/models'
11import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
12import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
13
14@Component({
15 selector: 'my-account-video-playlist-create',
16 templateUrl: './my-account-video-playlist-edit.component.html',
17 styleUrls: [ './my-account-video-playlist-edit.component.scss' ]
18})
19export class MyAccountVideoPlaylistCreateComponent extends MyAccountVideoPlaylistEdit implements OnInit {
20 error: string
21 videoPlaylistPrivacies: VideoConstant<VideoPlaylistPrivacy>[] = []
22
23 constructor (
24 protected formValidatorService: FormValidatorService,
25 private authService: AuthService,
26 private videoPlaylistValidatorsService: VideoPlaylistValidatorsService,
27 private notifier: Notifier,
28 private router: Router,
29 private videoPlaylistService: VideoPlaylistService,
30 private serverService: ServerService,
31 private i18n: I18n
32 ) {
33 super()
34 }
35
36 ngOnInit () {
37 this.buildForm({
38 displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME,
39 privacy: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_PRIVACY,
40 description: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DESCRIPTION,
41 videoChannelId: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_CHANNEL_ID,
42 thumbnailfile: null
43 })
44
45 this.form.get('privacy').valueChanges.subscribe(privacy => {
46 this.videoPlaylistValidatorsService.setChannelValidator(this.form.get('videoChannelId'), privacy)
47 })
48
49 populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
50
51 this.serverService.videoPlaylistPrivaciesLoaded.subscribe(
52 () => {
53 this.videoPlaylistPrivacies = this.serverService.getVideoPlaylistPrivacies()
54
55 this.form.patchValue({
56 privacy: VideoPlaylistPrivacy.PRIVATE
57 })
58 }
59 )
60 }
61
62 formValidated () {
63 this.error = undefined
64
65 const body = this.form.value
66 const videoPlaylistCreate: VideoPlaylistCreate = {
67 displayName: body.displayName,
68 privacy: body.privacy,
69 description: body.description || null,
70 videoChannelId: body.videoChannelId || null,
71 thumbnailfile: body.thumbnailfile || null
72 }
73
74 this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe(
75 () => {
76 this.notifier.success(
77 this.i18n('Playlist {{playlistName}} created.', { playlistName: videoPlaylistCreate.displayName })
78 )
79 this.router.navigate([ '/my-account', 'video-playlists' ])
80 },
81
82 err => this.error = err.message
83 )
84 }
85
86 isCreation () {
87 return true
88 }
89
90 getFormButtonTitle () {
91 return this.i18n('Create')
92 }
93}
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
new file mode 100644
index 000000000..303fc46f7
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
@@ -0,0 +1,69 @@
1<div i18n class="form-sub-title" *ngIf="isCreation() === true">Create a new playlist</div>
2
3<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
4
5<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
19 <div class="form-group">
20 <label i18n for="description">Description</label>
21 <textarea
22 id="description" formControlName="description"
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>
30
31 <div class="col-md-12 col-xl-6">
32 <div class="form-group">
33 <label i18n for="privacy">Privacy</label>
34 <div class="peertube-select-container">
35 <select id="privacy" formControlName="privacy">
36 <option *ngFor="let privacy of videoPlaylistPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
37 </select>
38 </div>
39
40 <div *ngIf="formErrors.privacy" class="form-error">
41 {{ formErrors.privacy }}
42 </div>
43 </div>
44
45 <div class="form-group">
46 <label i18n>Channel</label>
47 <div class="peertube-select-container">
48 <select formControlName="videoChannelId">
49 <option></option>
50 <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
51 </select>
52 </div>
53
54 <div *ngIf="formErrors['videoChannelId']" class="form-error">
55 {{ formErrors['videoChannelId'] }}
56 </div>
57 </div>
58
59 <div class="form-group">
60 <my-image-upload
61 i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile"
62 previewWidth="200px" previewHeight="110px"
63 ></my-image-upload>
64 </div>
65 </div>
66 </div>
67
68 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
69</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
new file mode 100644
index 000000000..5af846d8e
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss
@@ -0,0 +1,27 @@
1@import '_variables';
2@import '_mixins';
3
4.form-sub-title {
5 margin-bottom: 20px;
6}
7
8input[type=text] {
9 @include peertube-input-text(340px);
10
11 display: block;
12}
13
14textarea {
15 @include peertube-textarea(500px, 150px);
16
17 display: block;
18}
19
20.peertube-select-container {
21 @include peertube-select-container(340px);
22}
23
24input[type=submit] {
25 @include peertube-button;
26 @include orange-button;
27}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts
new file mode 100644
index 000000000..fbfb4c8f7
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts
@@ -0,0 +1,13 @@
1import { FormReactive } from '@app/shared'
2import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
3import { ServerService } from '@app/core'
4import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model'
5
6export abstract class MyAccountVideoPlaylistEdit extends FormReactive {
7 // Declare it here to avoid errors in create template
8 videoPlaylistToUpdate: VideoPlaylist
9 userVideoChannels: { id: number, label: string }[] = []
10
11 abstract isCreation (): boolean
12 abstract getFormButtonTitle (): string
13}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html
new file mode 100644
index 000000000..284694b7f
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html
@@ -0,0 +1,26 @@
1<div class="row">
2
3 <div class="playlist-info col-xs-12 col-md-5 col-xl-3">
4 <my-video-playlist-miniature
5 *ngIf="playlist" [playlist]="playlist" [toManage]="false" [displayChannel]="true"
6 [displayDescription]="true" [displayPrivacy]="true"
7 ></my-video-playlist-miniature>
8 </div>
9
10 <div class="playlist-elements col-xs-12 col-md-7 col-xl-9">
11 <div i18n class="no-results" *ngIf="pagination.totalItems === 0">No videos in this playlist.</div>
12
13 <div
14 class="videos" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()"
15 cdkDropList (cdkDropListDropped)="drop($event)"
16 >
17 <div class="video" *ngFor="let video of videos; trackBy: trackByFn" cdkDrag (cdkDragMoved)="onDragMove($event)">
18 <my-video-playlist-element-miniature
19 [video]="video" [playlist]="playlist" [owned]="true" (elementRemoved)="onElementRemoved($event)"
20 [position]="video.playlistElement.position"
21 >
22 </my-video-playlist-element-miniature>
23 </div>
24 </div>
25 </div>
26</div>
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
new file mode 100644
index 000000000..900669827
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss
@@ -0,0 +1,39 @@
1@import '_variables';
2@import '_mixins';
3@import '_miniature';
4
5.playlist-info {
6 background-color: var(--submenuColor);
7 margin-left: -15px;
8 margin-top: -$sub-menu-margin-bottom;
9
10 padding: $sub-menu-margin-bottom 0;
11
12 display: flex;
13 justify-content: center;
14}
15
16// Thanks Angular CDK <3 https://material.angular.io/cdk/drag-drop/examples
17.cdk-drag-preview {
18 box-sizing: border-box;
19 border-radius: 4px;
20 box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
21 0 8px 10px 1px rgba(0, 0, 0, 0.14),
22 0 3px 14px 2px rgba(0, 0, 0, 0.12);
23}
24
25.cdk-drag-placeholder {
26 opacity: 0;
27}
28
29.cdk-drag-animating {
30 transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
31}
32
33.video:last-child {
34 border: none;
35}
36
37.videos.cdk-drop-list-dragging .video:not(.cdk-drag-placeholder) {
38 transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
39}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts
new file mode 100644
index 000000000..25d51d2cb
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts
@@ -0,0 +1,153 @@
1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { Notifier, ServerService } from '@app/core'
3import { AuthService } from '../../core/auth'
4import { ConfirmService } from '../../core/confirm'
5import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
6import { Video } from '@app/shared/video/video.model'
7import { Subject, Subscription } from 'rxjs'
8import { ActivatedRoute } from '@angular/router'
9import { VideoService } from '@app/shared/video/video.service'
10import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
11import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
12import { I18n } from '@ngx-translate/i18n-polyfill'
13import { CdkDragDrop, CdkDragMove } from '@angular/cdk/drag-drop'
14import { throttleTime } from 'rxjs/operators'
15
16@Component({
17 selector: 'my-account-video-playlist-elements',
18 templateUrl: './my-account-video-playlist-elements.component.html',
19 styleUrls: [ './my-account-video-playlist-elements.component.scss' ]
20})
21export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy {
22 videos: Video[] = []
23 playlist: VideoPlaylist
24
25 pagination: ComponentPagination = {
26 currentPage: 1,
27 itemsPerPage: 30,
28 totalItems: null
29 }
30
31 private videoPlaylistId: string | number
32 private paramsSub: Subscription
33 private dragMoveSubject = new Subject<number>()
34
35 constructor (
36 private authService: AuthService,
37 private serverService: ServerService,
38 private notifier: Notifier,
39 private confirmService: ConfirmService,
40 private route: ActivatedRoute,
41 private i18n: I18n,
42 private videoService: VideoService,
43 private videoPlaylistService: VideoPlaylistService
44 ) {}
45
46 ngOnInit () {
47 this.paramsSub = this.route.params.subscribe(routeParams => {
48 this.videoPlaylistId = routeParams[ 'videoPlaylistId' ]
49 this.loadElements()
50
51 this.loadPlaylistInfo()
52 })
53
54 this.dragMoveSubject.asObservable()
55 .pipe(throttleTime(200))
56 .subscribe(y => this.checkScroll(y))
57 }
58
59 ngOnDestroy () {
60 if (this.paramsSub) this.paramsSub.unsubscribe()
61 }
62
63 drop (event: CdkDragDrop<any>) {
64 const previousIndex = event.previousIndex
65 const newIndex = event.currentIndex
66
67 if (previousIndex === newIndex) return
68
69 const oldPosition = this.videos[previousIndex].playlistElement.position
70 const insertAfter = newIndex === 0 ? 0 : this.videos[newIndex].playlistElement.position
71
72 this.videoPlaylistService.reorderPlaylist(this.playlist.id, oldPosition, insertAfter)
73 .subscribe(
74 () => { /* nothing to do */ },
75
76 err => this.notifier.error(err.message)
77 )
78
79 const video = this.videos[previousIndex]
80
81 this.videos.splice(previousIndex, 1)
82 this.videos.splice(newIndex, 0, video)
83
84 this.reorderClientPositions()
85 }
86
87 onDragMove (event: CdkDragMove<any>) {
88 this.dragMoveSubject.next(event.pointerPosition.y)
89 }
90
91 checkScroll (pointerY: number) {
92 // FIXME: Uncomment when https://github.com/angular/material2/issues/14098 is fixed
93 // FIXME: Remove when https://github.com/angular/material2/issues/13588 is implemented
94 // if (pointerY < 150) {
95 // window.scrollBy({
96 // left: 0,
97 // top: -20,
98 // behavior: 'smooth'
99 // })
100 //
101 // return
102 // }
103 //
104 // if (window.innerHeight - pointerY <= 50) {
105 // window.scrollBy({
106 // left: 0,
107 // top: 20,
108 // behavior: 'smooth'
109 // })
110 // }
111 }
112
113 onElementRemoved (video: Video) {
114 this.videos = this.videos.filter(v => v.id !== video.id)
115 this.reorderClientPositions()
116 }
117
118 onNearOfBottom () {
119 // Last page
120 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
121
122 this.pagination.currentPage += 1
123 this.loadElements()
124 }
125
126 trackByFn (index: number, elem: Video) {
127 return elem.id
128 }
129
130 private loadElements () {
131 this.videoService.getPlaylistVideos(this.videoPlaylistId, this.pagination)
132 .subscribe(({ totalVideos, videos }) => {
133 this.videos = this.videos.concat(videos)
134 this.pagination.totalItems = totalVideos
135 })
136 }
137
138 private loadPlaylistInfo () {
139 this.videoPlaylistService.getVideoPlaylist(this.videoPlaylistId)
140 .subscribe(playlist => {
141 this.playlist = playlist
142 })
143 }
144
145 private reorderClientPositions () {
146 let i = 1
147
148 for (const video of this.videos) {
149 video.playlistElement.position = i
150 i++
151 }
152 }
153}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts
new file mode 100644
index 000000000..4887fdfb4
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts
@@ -0,0 +1,136 @@
1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router'
3import { AuthService, Notifier, ServerService } from '@app/core'
4import { Subscription } from 'rxjs'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7import { MyAccountVideoPlaylistEdit } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-edit'
8import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
9import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
10import { VideoPlaylistValidatorsService } from '@app/shared'
11import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model'
12import { VideoConstant } from '@shared/models'
13import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
14import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
15
16@Component({
17 selector: 'my-account-video-playlist-update',
18 templateUrl: './my-account-video-playlist-edit.component.html',
19 styleUrls: [ './my-account-video-playlist-edit.component.scss' ]
20})
21export class MyAccountVideoPlaylistUpdateComponent extends MyAccountVideoPlaylistEdit implements OnInit, OnDestroy {
22 error: string
23 videoPlaylistToUpdate: VideoPlaylist
24 videoPlaylistPrivacies: VideoConstant<VideoPlaylistPrivacy>[] = []
25
26 private paramsSub: Subscription
27
28 constructor (
29 protected formValidatorService: FormValidatorService,
30 private authService: AuthService,
31 private videoPlaylistValidatorsService: VideoPlaylistValidatorsService,
32 private notifier: Notifier,
33 private router: Router,
34 private route: ActivatedRoute,
35 private videoPlaylistService: VideoPlaylistService,
36 private i18n: I18n,
37 private serverService: ServerService
38 ) {
39 super()
40 }
41
42 ngOnInit () {
43 this.buildForm({
44 displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME,
45 privacy: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_PRIVACY,
46 description: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DESCRIPTION,
47 videoChannelId: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_CHANNEL_ID,
48 thumbnailfile: null
49 })
50
51 this.form.get('privacy').valueChanges.subscribe(privacy => {
52 this.videoPlaylistValidatorsService.setChannelValidator(this.form.get('videoChannelId'), privacy)
53 })
54
55 populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
56
57 this.paramsSub = this.route.params.subscribe(routeParams => {
58 const videoPlaylistId = routeParams['videoPlaylistId']
59
60 this.videoPlaylistService.getVideoPlaylist(videoPlaylistId).subscribe(
61 videoPlaylistToUpdate => {
62 this.videoPlaylistToUpdate = videoPlaylistToUpdate
63
64 this.hydrateFormFromPlaylist()
65
66 this.serverService.videoPlaylistPrivaciesLoaded.subscribe(
67 () => {
68 this.videoPlaylistPrivacies = this.serverService.getVideoPlaylistPrivacies()
69 .filter(p => {
70 // If the playlist is not private, we cannot put it in private anymore
71 return this.videoPlaylistToUpdate.privacy.id === VideoPlaylistPrivacy.PRIVATE ||
72 p.id !== VideoPlaylistPrivacy.PRIVATE
73 })
74 }
75 )
76 },
77
78 err => this.error = err.message
79 )
80 })
81 }
82
83 ngOnDestroy () {
84 if (this.paramsSub) this.paramsSub.unsubscribe()
85 }
86
87 formValidated () {
88 this.error = undefined
89
90 const body = this.form.value
91 const videoPlaylistUpdate: VideoPlaylistUpdate = {
92 displayName: body.displayName,
93 privacy: body.privacy,
94 description: body.description || null,
95 videoChannelId: body.videoChannelId || null,
96 thumbnailfile: body.thumbnailfile || undefined
97 }
98
99 this.videoPlaylistService.updateVideoPlaylist(this.videoPlaylistToUpdate, videoPlaylistUpdate).subscribe(
100 () => {
101 this.notifier.success(
102 this.i18n('Playlist {{videoPlaylistName}} updated.', { videoPlaylistName: videoPlaylistUpdate.displayName })
103 )
104
105 this.router.navigate([ '/my-account', 'video-playlists' ])
106 },
107
108 err => this.error = err.message
109 )
110 }
111
112 isCreation () {
113 return false
114 }
115
116 getFormButtonTitle () {
117 return this.i18n('Update')
118 }
119
120 private hydrateFormFromPlaylist () {
121 this.form.patchValue({
122 displayName: this.videoPlaylistToUpdate.displayName,
123 privacy: this.videoPlaylistToUpdate.privacy.id,
124 description: this.videoPlaylistToUpdate.description,
125 videoChannelId: this.videoPlaylistToUpdate.videoChannel ? this.videoPlaylistToUpdate.videoChannel.id : null
126 })
127
128 fetch(this.videoPlaylistToUpdate.thumbnailUrl)
129 .then(response => response.blob())
130 .then(data => {
131 this.form.patchValue({
132 thumbnailfile: data
133 })
134 })
135 }
136}
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
new file mode 100644
index 000000000..322560673
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
@@ -0,0 +1,21 @@
1<div class="video-playlists-header">
2 <a class="create-button" routerLink="create">
3 <my-global-icon iconName="add"></my-global-icon>
4 <ng-container i18n>Create a new playlist</ng-container>
5 </a>
6</div>
7
8<div class="video-playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()">
9 <div *ngFor="let playlist of videoPlaylists" class="video-playlist">
10 <div class="miniature-wrapper">
11 <my-video-playlist-miniature [playlist]="playlist" [toManage]="true" [displayChannel]="true" [displayDescription]="true" [displayPrivacy]="true"
12 ></my-video-playlist-miniature>
13 </div>
14
15 <div *ngIf="isRegularPlaylist(playlist)" class="video-playlist-buttons">
16 <my-delete-button (click)="deleteVideoPlaylist(playlist)"></my-delete-button>
17
18 <my-edit-button [routerLink]="[ 'update', playlist.uuid ]"></my-edit-button>
19 </div>
20 </div>
21</div>
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
new file mode 100644
index 000000000..f648c33e4
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss
@@ -0,0 +1,51 @@
1@import '_variables';
2@import '_mixins';
3
4.create-button {
5 @include create-button;
6}
7
8/deep/ .action-button {
9 &.action-button-delete {
10 margin-right: 10px;
11 }
12}
13
14.video-playlist {
15 @include row-blocks;
16
17 .miniature-wrapper {
18 flex-grow: 1;
19
20 /deep/ .miniature {
21 display: flex;
22
23 .miniature-info {
24 margin-left: 10px;
25 width: auto;
26 }
27 }
28 }
29
30 .video-playlist-buttons {
31 min-width: 190px;
32 }
33}
34
35.video-playlists-header {
36 text-align: right;
37 margin: 20px 0 50px;
38}
39
40@media screen and (max-width: 800px) {
41 .video-playlists-header {
42 text-align: center;
43 }
44
45 .video-playlist {
46
47 .video-playlist-buttons {
48 margin-top: 10px;
49 }
50 }
51}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
new file mode 100644
index 000000000..e30656b92
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
@@ -0,0 +1,88 @@
1import { Component, OnInit } from '@angular/core'
2import { Notifier } from '@app/core'
3import { AuthService } from '../../core/auth'
4import { ConfirmService } from '../../core/confirm'
5import { User } from '@app/shared'
6import { flatMap } from 'rxjs/operators'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
9import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
10import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
11import { VideoPlaylistType } from '@shared/models'
12
13@Component({
14 selector: 'my-account-video-playlists',
15 templateUrl: './my-account-video-playlists.component.html',
16 styleUrls: [ './my-account-video-playlists.component.scss' ]
17})
18export class MyAccountVideoPlaylistsComponent implements OnInit {
19 videoPlaylists: VideoPlaylist[] = []
20
21 pagination: ComponentPagination = {
22 currentPage: 1,
23 itemsPerPage: 10,
24 totalItems: null
25 }
26
27 private user: User
28
29 constructor (
30 private authService: AuthService,
31 private notifier: Notifier,
32 private confirmService: ConfirmService,
33 private videoPlaylistService: VideoPlaylistService,
34 private i18n: I18n
35 ) {}
36
37 ngOnInit () {
38 this.user = this.authService.getUser()
39
40 this.loadVideoPlaylists()
41 }
42
43 async deleteVideoPlaylist (videoPlaylist: VideoPlaylist) {
44 const res = await this.confirmService.confirm(
45 this.i18n(
46 'Do you really want to delete {{playlistDisplayName}}?',
47 { playlistDisplayName: videoPlaylist.displayName }
48 ),
49 this.i18n('Delete')
50 )
51 if (res === false) return
52
53 this.videoPlaylistService.removeVideoPlaylist(videoPlaylist)
54 .subscribe(
55 () => {
56 this.videoPlaylists = this.videoPlaylists
57 .filter(p => p.id !== videoPlaylist.id)
58
59 this.notifier.success(
60 this.i18n('Playlist {{playlistDisplayName}} deleted.', { playlistDisplayName: videoPlaylist.displayName })
61 )
62 },
63
64 error => this.notifier.error(error.message)
65 )
66 }
67
68 isRegularPlaylist (playlist: VideoPlaylist) {
69 return playlist.type.id === VideoPlaylistType.REGULAR
70 }
71
72 onNearOfBottom () {
73 // Last page
74 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
75
76 this.pagination.currentPage += 1
77 this.loadVideoPlaylists()
78 }
79
80 private loadVideoPlaylists () {
81 this.authService.userInformationLoaded
82 .pipe(flatMap(() => this.videoPlaylistService.listAccountPlaylists(this.user.account, '-updatedAt')))
83 .subscribe(res => {
84 this.videoPlaylists = this.videoPlaylists.concat(res.data)
85 this.pagination.totalItems = res.total
86 })
87 }
88}
diff --git a/client/src/app/+my-account/my-account-videos/my-account-videos.component.html b/client/src/app/+my-account/my-account-videos/my-account-videos.component.html
index 69748ef37..d7993fdc2 100644
--- a/client/src/app/+my-account/my-account-videos/my-account-videos.component.html
+++ b/client/src/app/+my-account/my-account-videos/my-account-videos.component.html
@@ -1,56 +1,30 @@
1<div i18n *ngIf="pagination.totalItems === 0">No results.</div> 1<my-videos-selection
2 2 [(selection)]="selection"
3<div 3 [(videosModel)]="videos"
4 myInfiniteScroller 4 [miniatureDisplayOptions]="miniatureDisplayOptions"
5 [pageHeight]="pageHeight" 5 [titlePage]="titlePage"
6 (nearOfTop)="onNearOfTop()" (nearOfBottom)="onNearOfBottom()" (pageChanged)="onPageChanged($event)" 6 [getVideosObservableFunction]="getVideosObservableFunction"
7 class="videos" #videosElement 7 #videosSelection
8> 8>
9 <div *ngFor="let videos of videoPages; let i = index" class="videos-page"> 9 <ng-template ptTemplate="globalButtons">
10 <div class="video" *ngFor="let video of videos; let j = index"> 10 <span class="action-button action-button-delete-selection" (click)="deleteSelectedVideos()">
11 <div class="checkbox-container"> 11 <my-global-icon iconName="delete"></my-global-icon>
12 <my-peertube-checkbox [inputName]="'video-check-' + video.id" [(ngModel)]="checkedVideos[video.id]"></my-peertube-checkbox> 12 <ng-container i18n>Delete</ng-container>
13 </div> 13 </span>
14 14 </ng-template>
15 <my-video-thumbnail [video]="video"></my-video-thumbnail> 15
16 16 <ng-template ptTemplate="rowButtons" let-video>
17 <div class="video-info"> 17 <my-delete-button (click)="deleteVideo(video)"></my-delete-button>
18 <a class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a> 18
19 <span i18n class="video-info-date-views">{{ video.createdAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span> 19 <my-edit-button [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button>
20 <div class="video-info-private">{{ video.privacy.label }}{{ getStateLabel(video) }}</div> 20
21 <div *ngIf="video.blacklisted" class="video-info-blacklisted"> 21 <my-button i18n-label label="Change ownership"
22 <span class="blacklisted-label" i18n>Blacklisted</span> 22 className="action-button-change-ownership"
23 <span class="blacklisted-reason" *ngIf="video.blacklistedReason">{{ video.blacklistedReason }}</span> 23 icon="im-with-her"
24 </div> 24 (click)="changeOwnership($event, video)"
25 </div> 25 ></my-button>
26 26 </ng-template>
27 <!-- Display only once --> 27</my-videos-selection>
28 <div class="action-selection-mode" *ngIf="isInSelectionMode() === true && i === 0 && j === 0">
29 <div class="action-selection-mode-child">
30 <span i18n class="action-button action-button-cancel-selection" (click)="abortSelectionMode()">
31 Cancel
32 </span>
33
34 <span class="action-button action-button-delete-selection" (click)="deleteSelectedVideos()">
35 <my-global-icon iconName="delete"></my-global-icon>
36 <ng-container i18n>Delete</ng-container>
37 </span>
38 </div>
39 </div>
40
41 <div class="video-buttons" *ngIf="isInSelectionMode() === false">
42 <my-delete-button (click)="deleteVideo(video)"></my-delete-button>
43
44 <my-edit-button [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button>
45 28
46 <my-button i18n-label label="Change ownership"
47 className="action-button-change-ownership"
48 icon="im-with-her"
49 (click)="changeOwnership($event, video)"
50 ></my-button>
51 </div>
52 </div>
53 </div>
54</div>
55 29
56<my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership> 30<my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership>
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 39d0cf2f7..87398e7c8 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
@@ -1,119 +1,19 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4.action-selection-mode { 4.action-button-delete-selection {
5 width: 174px; 5 display: inline-block;
6 display: flex;
7 justify-content: flex-end;
8 6
9 .action-selection-mode-child { 7 @include peertube-button;
10 position: fixed; 8 @include orange-button;
9 @include button-with-icon(21px);
11 10
12 .action-button { 11 my-global-icon {
13 display: inline-block; 12 @include apply-svg-color(#fff);
14 }
15
16 .action-button-cancel-selection {
17 @include peertube-button;
18 @include grey-button;
19
20 margin-right: 10px;
21 }
22
23 .action-button-delete-selection {
24 @include peertube-button;
25 @include orange-button;
26 @include button-with-icon(21px);
27
28 my-global-icon {
29 @include apply-svg-color(#fff);
30 }
31 }
32 }
33}
34
35.video {
36 @include row-blocks;
37
38 &:first-child {
39 margin-top: 47px;
40 }
41
42 .checkbox-container {
43 display: flex;
44 align-items: center;
45 margin-right: 20px;
46 margin-left: 12px;
47 }
48
49 my-video-thumbnail {
50 margin-right: 10px;
51 }
52
53 .video-info {
54 flex-grow: 1;
55
56 .video-info-name {
57 @include disable-default-a-behaviour;
58
59 color: var(--mainForegroundColor);
60 display: block;
61 width: fit-content;
62 font-size: 16px;
63 font-weight: $font-semibold;
64 }
65
66 .video-info-date-views,
67 .video-info-private,
68 .video-info-blacklisted {
69 font-size: 13px;
70
71 &.video-info-private,
72 &.video-info-blacklisted .blacklisted-label {
73 font-weight: $font-semibold;
74 }
75
76 &.video-info-blacklisted {
77 color: red;
78
79 .blacklisted-reason {
80 &::before {
81 content: ' - ';
82 }
83 }
84 }
85 }
86 }
87
88 .video-buttons {
89 min-width: 190px;
90
91 *:not(:last-child) {
92 margin-right: 10px;
93 }
94 } 13 }
95} 14}
96 15
97@media screen and (max-width: $small-view) { 16my-delete-button,
98 .video { 17my-edit-button {
99 flex-direction: column; 18 margin-right: 10px;
100 height: auto;
101 text-align: center;
102
103 .video-info-name {
104 margin: auto;
105 }
106
107 input[type=checkbox] {
108 display: none;
109 }
110
111 my-video-thumbnail {
112 margin-right: 0;
113 }
114
115 .video-buttons {
116 margin-top: 10px;
117 }
118 }
119} 19}
diff --git a/client/src/app/+my-account/my-account-videos/my-account-videos.component.ts b/client/src/app/+my-account/my-account-videos/my-account-videos.component.ts
index 41608f796..5f29364a8 100644
--- a/client/src/app/+my-account/my-account-videos/my-account-videos.component.ts
+++ b/client/src/app/+my-account/my-account-videos/my-account-videos.component.ts
@@ -1,87 +1,81 @@
1import { from as observableFrom, Observable } from 'rxjs' 1import { concat, Observable } from 'rxjs'
2import { concatAll, tap } from 'rxjs/operators' 2import { tap, toArray } from 'rxjs/operators'
3import { Component, OnDestroy, OnInit, Inject, LOCALE_ID, ViewChild } from '@angular/core' 3import { Component, ViewChild } from '@angular/core'
4import { ActivatedRoute, Router } from '@angular/router' 4import { ActivatedRoute, Router } from '@angular/router'
5import { Location } from '@angular/common'
6import { immutableAssign } from '@app/shared/misc/utils' 5import { immutableAssign } from '@app/shared/misc/utils'
7import { ComponentPagination } from '@app/shared/rest/component-pagination.model' 6import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
8import { Notifier } from '@app/core' 7import { Notifier, ServerService } from '@app/core'
9import { AuthService } from '../../core/auth' 8import { AuthService } from '../../core/auth'
10import { ConfirmService } from '../../core/confirm' 9import { ConfirmService } from '../../core/confirm'
11import { AbstractVideoList } from '../../shared/video/abstract-video-list'
12import { Video } from '../../shared/video/video.model' 10import { Video } from '../../shared/video/video.model'
13import { VideoService } from '../../shared/video/video.service' 11import { VideoService } from '../../shared/video/video.service'
14import { I18n } from '@ngx-translate/i18n-polyfill' 12import { I18n } from '@ngx-translate/i18n-polyfill'
15import { VideoPrivacy, VideoState } from '../../../../../shared/models/videos'
16import { ScreenService } from '@app/shared/misc/screen.service' 13import { ScreenService } from '@app/shared/misc/screen.service'
17import { VideoChangeOwnershipComponent } from './video-change-ownership/video-change-ownership.component' 14import { VideoChangeOwnershipComponent } from './video-change-ownership/video-change-ownership.component'
15import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component'
16import { SelectionType, VideosSelectionComponent } from '@app/shared/video/videos-selection.component'
17import { VideoSortField } from '@app/shared/video/sort-field.type'
18import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
18 19
19@Component({ 20@Component({
20 selector: 'my-account-videos', 21 selector: 'my-account-videos',
21 templateUrl: './my-account-videos.component.html', 22 templateUrl: './my-account-videos.component.html',
22 styleUrls: [ './my-account-videos.component.scss' ] 23 styleUrls: [ './my-account-videos.component.scss' ]
23}) 24})
24export class MyAccountVideosComponent extends AbstractVideoList implements OnInit, OnDestroy { 25export class MyAccountVideosComponent implements DisableForReuseHook {
26 @ViewChild('videosSelection') videosSelection: VideosSelectionComponent
27 @ViewChild('videoChangeOwnershipModal') videoChangeOwnershipModal: VideoChangeOwnershipComponent
28
25 titlePage: string 29 titlePage: string
26 currentRoute = '/my-account/videos' 30 selection: SelectionType = {}
27 checkedVideos: { [ id: number ]: boolean } = {}
28 pagination: ComponentPagination = { 31 pagination: ComponentPagination = {
29 currentPage: 1, 32 currentPage: 1,
30 itemsPerPage: 5, 33 itemsPerPage: 5,
31 totalItems: null 34 totalItems: null
32 } 35 }
33 36 miniatureDisplayOptions: MiniatureDisplayOptions = {
34 protected baseVideoWidth = -1 37 date: true,
35 protected baseVideoHeight = 155 38 views: true,
36 39 by: false,
37 @ViewChild('videoChangeOwnershipModal') videoChangeOwnershipModal: VideoChangeOwnershipComponent 40 privacyLabel: false,
41 privacyText: true,
42 state: true,
43 blacklistInfo: true
44 }
45 videos: Video[] = []
46 getVideosObservableFunction = this.getVideosObservable.bind(this)
38 47
39 constructor ( 48 constructor (
40 protected router: Router, 49 protected router: Router,
50 protected serverService: ServerService,
41 protected route: ActivatedRoute, 51 protected route: ActivatedRoute,
42 protected authService: AuthService, 52 protected authService: AuthService,
43 protected notifier: Notifier, 53 protected notifier: Notifier,
44 protected location: Location,
45 protected screenService: ScreenService, 54 protected screenService: ScreenService,
46 protected i18n: I18n, 55 private i18n: I18n,
47 private confirmService: ConfirmService, 56 private confirmService: ConfirmService,
48 private videoService: VideoService, 57 private videoService: VideoService
49 @Inject(LOCALE_ID) private localeId: string
50 ) { 58 ) {
51 super()
52
53 this.titlePage = this.i18n('My videos') 59 this.titlePage = this.i18n('My videos')
54 } 60 }
55 61
56 ngOnInit () { 62 disableForReuse () {
57 super.ngOnInit() 63 this.videosSelection.disableForReuse()
58 }
59
60 ngOnDestroy () {
61 super.ngOnDestroy()
62 }
63
64 abortSelectionMode () {
65 this.checkedVideos = {}
66 } 64 }
67 65
68 isInSelectionMode () { 66 enabledForReuse () {
69 return Object.keys(this.checkedVideos).some(k => this.checkedVideos[ k ] === true) 67 this.videosSelection.enabledForReuse()
70 } 68 }
71 69
72 getVideosObservable (page: number) { 70 getVideosObservable (page: number, sort: VideoSortField) {
73 const newPagination = immutableAssign(this.pagination, { currentPage: page }) 71 const newPagination = immutableAssign(this.pagination, { currentPage: page })
74 72
75 return this.videoService.getMyVideos(newPagination, this.sort) 73 return this.videoService.getMyVideos(newPagination, sort)
76 }
77
78 generateSyndicationList () {
79 throw new Error('Method not implemented.')
80 } 74 }
81 75
82 async deleteSelectedVideos () { 76 async deleteSelectedVideos () {
83 const toDeleteVideosIds = Object.keys(this.checkedVideos) 77 const toDeleteVideosIds = Object.keys(this.selection)
84 .filter(k => this.checkedVideos[ k ] === true) 78 .filter(k => this.selection[ k ] === true)
85 .map(k => parseInt(k, 10)) 79 .map(k => parseInt(k, 10))
86 80
87 const res = await this.confirmService.confirm( 81 const res = await this.confirmService.confirm(
@@ -93,19 +87,18 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni
93 const observables: Observable<any>[] = [] 87 const observables: Observable<any>[] = []
94 for (const videoId of toDeleteVideosIds) { 88 for (const videoId of toDeleteVideosIds) {
95 const o = this.videoService.removeVideo(videoId) 89 const o = this.videoService.removeVideo(videoId)
96 .pipe(tap(() => this.spliceVideosById(videoId))) 90 .pipe(tap(() => this.removeVideoFromArray(videoId)))
97 91
98 observables.push(o) 92 observables.push(o)
99 } 93 }
100 94
101 observableFrom(observables) 95 concat(...observables)
102 .pipe(concatAll()) 96 .pipe(toArray())
103 .subscribe( 97 .subscribe(
104 res => { 98 () => {
105 this.notifier.success(this.i18n('{{deleteLength}} videos deleted.', { deleteLength: toDeleteVideosIds.length })) 99 this.notifier.success(this.i18n('{{deleteLength}} videos deleted.', { deleteLength: toDeleteVideosIds.length }))
106 100
107 this.abortSelectionMode() 101 this.selection = {}
108 this.reloadVideos()
109 }, 102 },
110 103
111 err => this.notifier.error(err.message) 104 err => this.notifier.error(err.message)
@@ -123,7 +116,7 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni
123 .subscribe( 116 .subscribe(
124 () => { 117 () => {
125 this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: video.name })) 118 this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: video.name }))
126 this.reloadVideos() 119 this.removeVideoFromArray(video.id)
127 }, 120 },
128 121
129 error => this.notifier.error(error.message) 122 error => this.notifier.error(error.message)
@@ -135,41 +128,7 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni
135 this.videoChangeOwnershipModal.show(video) 128 this.videoChangeOwnershipModal.show(video)
136 } 129 }
137 130
138 getStateLabel (video: Video) { 131 private removeVideoFromArray (id: number) {
139 let suffix: string 132 this.videos = this.videos.filter(v => v.id !== id)
140
141 if (video.privacy.id !== VideoPrivacy.PRIVATE && video.state.id === VideoState.PUBLISHED) {
142 suffix = this.i18n('Published')
143 } else if (video.scheduledUpdate) {
144 const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
145 suffix = this.i18n('Publication scheduled on ') + updateAt
146 } else if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) {
147 suffix = this.i18n('Waiting transcoding')
148 } else if (video.state.id === VideoState.TO_TRANSCODE) {
149 suffix = this.i18n('To transcode')
150 } else if (video.state.id === VideoState.TO_IMPORT) {
151 suffix = this.i18n('To import')
152 } else {
153 return ''
154 }
155
156 return ' - ' + suffix
157 }
158
159 protected buildVideoHeight () {
160 // In account videos, the video height is fixed
161 return this.baseVideoHeight
162 }
163
164 private spliceVideosById (id: number) {
165 for (const key of Object.keys(this.loadedPages)) {
166 const videos: Video[] = this.loadedPages[ key ]
167 const index = videos.findIndex(v => v.id === id)
168
169 if (index !== -1) {
170 videos.splice(index, 1)
171 return
172 }
173 }
174 } 133 }
175} 134}
diff --git a/client/src/app/+my-account/my-account.component.ts b/client/src/app/+my-account/my-account.component.ts
index 8a4102d80..d98d06f8e 100644
--- a/client/src/app/+my-account/my-account.component.ts
+++ b/client/src/app/+my-account/my-account.component.ts
@@ -21,19 +21,28 @@ export class MyAccountComponent {
21 children: [ 21 children: [
22 { 22 {
23 label: this.i18n('My channels'), 23 label: this.i18n('My channels'),
24 routerLink: '/my-account/video-channels' 24 routerLink: '/my-account/video-channels',
25 iconName: 'folder'
25 }, 26 },
26 { 27 {
27 label: this.i18n('My videos'), 28 label: this.i18n('My videos'),
28 routerLink: '/my-account/videos' 29 routerLink: '/my-account/videos',
30 iconName: 'videos'
31 },
32 {
33 label: this.i18n('My playlists'),
34 routerLink: '/my-account/video-playlists',
35 iconName: 'playlists'
29 }, 36 },
30 { 37 {
31 label: this.i18n('My subscriptions'), 38 label: this.i18n('My subscriptions'),
32 routerLink: '/my-account/subscriptions' 39 routerLink: '/my-account/subscriptions',
40 iconName: 'subscriptions'
33 }, 41 },
34 { 42 {
35 label: this.i18n('My history'), 43 label: this.i18n('My history'),
36 routerLink: '/my-account/history/videos' 44 routerLink: '/my-account/history/videos',
45 iconName: 'history'
37 } 46 }
38 ] 47 ]
39 } 48 }
@@ -41,7 +50,8 @@ export class MyAccountComponent {
41 if (this.isVideoImportEnabled()) { 50 if (this.isVideoImportEnabled()) {
42 libraryEntries.children.push({ 51 libraryEntries.children.push({
43 label: 'My imports', 52 label: 'My imports',
44 routerLink: '/my-account/video-imports' 53 routerLink: '/my-account/video-imports',
54 iconName: 'cloud-download'
45 }) 55 })
46 } 56 }
47 57
@@ -50,15 +60,18 @@ export class MyAccountComponent {
50 children: [ 60 children: [
51 { 61 {
52 label: this.i18n('Muted accounts'), 62 label: this.i18n('Muted accounts'),
53 routerLink: '/my-account/blocklist/accounts' 63 routerLink: '/my-account/blocklist/accounts',
64 iconName: 'user'
54 }, 65 },
55 { 66 {
56 label: this.i18n('Muted instances'), 67 label: this.i18n('Muted instances'),
57 routerLink: '/my-account/blocklist/servers' 68 routerLink: '/my-account/blocklist/servers',
69 iconName: 'server'
58 }, 70 },
59 { 71 {
60 label: this.i18n('Ownership changes'), 72 label: this.i18n('Ownership changes'),
61 routerLink: '/my-account/ownership' 73 routerLink: '/my-account/ownership',
74 iconName: 'im-with-her'
62 } 75 }
63 ] 76 ]
64 } 77 }
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index 18f51f171..4a18a9968 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -25,6 +25,17 @@ import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-b
25import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' 25import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
26import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component' 26import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
27import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences' 27import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences'
28import {
29 MyAccountVideoPlaylistCreateComponent
30} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
31import {
32 MyAccountVideoPlaylistUpdateComponent
33} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
34import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
35import {
36 MyAccountVideoPlaylistElementsComponent
37} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
38import { DragDropModule } from '@angular/cdk/drag-drop'
28 39
29@NgModule({ 40@NgModule({
30 imports: [ 41 imports: [
@@ -33,7 +44,8 @@ import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-a
33 AutoCompleteModule, 44 AutoCompleteModule,
34 SharedModule, 45 SharedModule,
35 TableModule, 46 TableModule,
36 InputSwitchModule 47 InputSwitchModule,
48 DragDropModule
37 ], 49 ],
38 50
39 declarations: [ 51 declarations: [
@@ -57,7 +69,12 @@ import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-a
57 MyAccountServerBlocklistComponent, 69 MyAccountServerBlocklistComponent,
58 MyAccountHistoryComponent, 70 MyAccountHistoryComponent,
59 MyAccountNotificationsComponent, 71 MyAccountNotificationsComponent,
60 MyAccountNotificationPreferencesComponent 72 MyAccountNotificationPreferencesComponent,
73
74 MyAccountVideoPlaylistCreateComponent,
75 MyAccountVideoPlaylistUpdateComponent,
76 MyAccountVideoPlaylistsComponent,
77 MyAccountVideoPlaylistElementsComponent
61 ], 78 ],
62 79
63 exports: [ 80 exports: [
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 0b0c83de5..86f8108b9 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
@@ -18,6 +18,10 @@
18 .actor-info-display-name { 18 .actor-info-display-name {
19 font-size: 20px; 19 font-size: 20px;
20 font-weight: $font-bold; 20 font-weight: $font-bold;
21
22 @media screen and (max-width: $small-view) {
23 font-size: 16px;
24 }
21 } 25 }
22 26
23 .actor-info-username { 27 .actor-info-username {
@@ -48,4 +52,4 @@
48 52
49 position: relative; 53 position: relative;
50 top: -10px; 54 top: -10px;
51} \ No newline at end of file 55}
diff --git a/client/src/app/+video-channels/video-channel-about/video-channel-about.component.ts b/client/src/app/+video-channels/video-channel-about/video-channel-about.component.ts
index 895b19064..11f9391e1 100644
--- a/client/src/app/+video-channels/video-channel-about/video-channel-about.component.ts
+++ b/client/src/app/+video-channels/video-channel-about/video-channel-about.component.ts
@@ -26,11 +26,11 @@ export class VideoChannelAboutComponent implements OnInit, OnDestroy {
26 ngOnInit () { 26 ngOnInit () {
27 // Parent get the video channel for us 27 // Parent get the video channel for us
28 this.videoChannelSub = this.videoChannelService.videoChannelLoaded 28 this.videoChannelSub = this.videoChannelService.videoChannelLoaded
29 .subscribe(videoChannel => { 29 .subscribe(async videoChannel => {
30 this.videoChannel = videoChannel 30 this.videoChannel = videoChannel
31 31
32 this.descriptionHTML = this.markdownService.textMarkdownToHTML(this.videoChannel.description) 32 this.descriptionHTML = await this.markdownService.textMarkdownToHTML(this.videoChannel.description)
33 this.supportHTML = this.markdownService.enhancedMarkdownToHTML(this.videoChannel.support) 33 this.supportHTML = await this.markdownService.enhancedMarkdownToHTML(this.videoChannel.support)
34 }) 34 })
35 } 35 }
36 36
diff --git a/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.html b/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.html
new file mode 100644
index 000000000..befc7143c
--- /dev/null
+++ b/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.html
@@ -0,0 +1,11 @@
1<div i18n class="title-page title-page-single">
2 Created {{ pagination.totalItems }} playlists
3</div>
4
5<div i18n class="no-results" *ngIf="pagination.totalItems === 0">This channel does not have playlists.</div>
6
7<div class="video-playlist" myInfiniteScroller (nearOfBottom)="onNearOfBottom()">
8 <div *ngFor="let playlist of videoPlaylists">
9 <my-video-playlist-miniature [playlist]="playlist" [toManage]="false"></my-video-playlist-miniature>
10 </div>
11</div>
diff --git a/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.scss b/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.scss
new file mode 100644
index 000000000..fe9104794
--- /dev/null
+++ b/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.scss
@@ -0,0 +1,9 @@
1.video-playlist {
2 display: flex;
3 justify-content: center;
4
5 my-video-playlist-miniature {
6 margin-right: 15px;
7 margin-bottom: 30px;
8 }
9}
diff --git a/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.ts b/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.ts
new file mode 100644
index 000000000..907aefae1
--- /dev/null
+++ b/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.ts
@@ -0,0 +1,63 @@
1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ConfirmService } from '../../core/confirm'
3import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
4import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
5import { Subscription } from 'rxjs'
6import { Notifier } from '@app/core'
7import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
8import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
9import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
10
11@Component({
12 selector: 'my-video-channel-playlists',
13 templateUrl: './video-channel-playlists.component.html',
14 styleUrls: [ './video-channel-playlists.component.scss' ]
15})
16export class VideoChannelPlaylistsComponent implements OnInit, OnDestroy {
17 videoPlaylists: VideoPlaylist[] = []
18
19 pagination: ComponentPagination = {
20 currentPage: 1,
21 itemsPerPage: 20,
22 totalItems: null
23 }
24
25 private videoChannelSub: Subscription
26 private videoChannel: VideoChannel
27
28 constructor (
29 private notifier: Notifier,
30 private confirmService: ConfirmService,
31 private videoPlaylistService: VideoPlaylistService,
32 private videoChannelService: VideoChannelService
33 ) {}
34
35 ngOnInit () {
36 // Parent get the video channel for us
37 this.videoChannelSub = this.videoChannelService.videoChannelLoaded
38 .subscribe(videoChannel => {
39 this.videoChannel = videoChannel
40 this.loadVideoPlaylists()
41 })
42 }
43
44 ngOnDestroy () {
45 if (this.videoChannelSub) this.videoChannelSub.unsubscribe()
46 }
47
48 onNearOfBottom () {
49 // Last page
50 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
51
52 this.pagination.currentPage += 1
53 this.loadVideoPlaylists()
54 }
55
56 private loadVideoPlaylists () {
57 this.videoPlaylistService.listChannelPlaylists(this.videoChannel)
58 .subscribe(res => {
59 this.videoPlaylists = this.videoPlaylists.concat(res.data)
60 this.pagination.totalItems = res.total
61 })
62 }
63}
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 dea378a6e..5e60b34b4 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
@@ -1,6 +1,5 @@
1import { Component, OnDestroy, OnInit } from '@angular/core' 1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { Location } from '@angular/common'
4import { immutableAssign } from '@app/shared/misc/utils' 3import { immutableAssign } from '@app/shared/misc/utils'
5import { AuthService } from '../../core/auth' 4import { AuthService } from '../../core/auth'
6import { ConfirmService } from '../../core/confirm' 5import { ConfirmService } from '../../core/confirm'
@@ -8,11 +7,11 @@ import { AbstractVideoList } from '../../shared/video/abstract-video-list'
8import { VideoService } from '../../shared/video/video.service' 7import { VideoService } from '../../shared/video/video.service'
9import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 8import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
10import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 9import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
11import { tap } from 'rxjs/operators' 10import { first, tap } from 'rxjs/operators'
12import { I18n } from '@ngx-translate/i18n-polyfill' 11import { I18n } from '@ngx-translate/i18n-polyfill'
13import { Subscription } from 'rxjs' 12import { Subscription } from 'rxjs'
14import { ScreenService } from '@app/shared/misc/screen.service' 13import { ScreenService } from '@app/shared/misc/screen.service'
15import { Notifier } from '@app/core' 14import { Notifier, ServerService } from '@app/core'
16 15
17@Component({ 16@Component({
18 selector: 'my-video-channel-videos', 17 selector: 'my-video-channel-videos',
@@ -24,8 +23,6 @@ import { Notifier } from '@app/core'
24}) 23})
25export class VideoChannelVideosComponent extends AbstractVideoList implements OnInit, OnDestroy { 24export class VideoChannelVideosComponent extends AbstractVideoList implements OnInit, OnDestroy {
26 titlePage: string 25 titlePage: string
27 marginContent = false // Disable margin
28 currentRoute = '/video-channels/videos'
29 loadOnInit = false 26 loadOnInit = false
30 27
31 private videoChannel: VideoChannel 28 private videoChannel: VideoChannel
@@ -33,13 +30,13 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
33 30
34 constructor ( 31 constructor (
35 protected router: Router, 32 protected router: Router,
33 protected serverService: ServerService,
36 protected route: ActivatedRoute, 34 protected route: ActivatedRoute,
37 protected authService: AuthService, 35 protected authService: AuthService,
38 protected notifier: Notifier, 36 protected notifier: Notifier,
39 protected confirmService: ConfirmService, 37 protected confirmService: ConfirmService,
40 protected location: Location,
41 protected screenService: ScreenService, 38 protected screenService: ScreenService,
42 protected i18n: I18n, 39 private i18n: I18n,
43 private videoChannelService: VideoChannelService, 40 private videoChannelService: VideoChannelService,
44 private videoService: VideoService 41 private videoService: VideoService
45 ) { 42 ) {
@@ -53,13 +50,13 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
53 50
54 // Parent get the video channel for us 51 // Parent get the video channel for us
55 this.videoChannelSub = this.videoChannelService.videoChannelLoaded 52 this.videoChannelSub = this.videoChannelService.videoChannelLoaded
56 .subscribe(videoChannel => { 53 .pipe(first())
57 this.videoChannel = videoChannel 54 .subscribe(videoChannel => {
58 this.currentRoute = '/video-channels/' + this.videoChannel.nameWithHost + '/videos' 55 this.videoChannel = videoChannel
59 56
60 this.reloadVideos() 57 this.reloadVideos()
61 this.generateSyndicationList() 58 this.generateSyndicationList()
62 }) 59 })
63 } 60 }
64 61
65 ngOnDestroy () { 62 ngOnDestroy () {
diff --git a/client/src/app/+video-channels/video-channels-routing.module.ts b/client/src/app/+video-channels/video-channels-routing.module.ts
index 3ac3533d9..d4872a0a5 100644
--- a/client/src/app/+video-channels/video-channels-routing.module.ts
+++ b/client/src/app/+video-channels/video-channels-routing.module.ts
@@ -4,6 +4,7 @@ import { MetaGuard } from '@ngx-meta/core'
4import { VideoChannelsComponent } from './video-channels.component' 4import { VideoChannelsComponent } from './video-channels.component'
5import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component' 5import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component'
6import { VideoChannelAboutComponent } from './video-channel-about/video-channel-about.component' 6import { VideoChannelAboutComponent } from './video-channel-about/video-channel-about.component'
7import { VideoChannelPlaylistsComponent } from '@app/+video-channels/video-channel-playlists/video-channel-playlists.component'
7 8
8const videoChannelsRoutes: Routes = [ 9const videoChannelsRoutes: Routes = [
9 { 10 {
@@ -22,6 +23,19 @@ const videoChannelsRoutes: Routes = [
22 data: { 23 data: {
23 meta: { 24 meta: {
24 title: 'Video channel videos' 25 title: 'Video channel videos'
26 },
27 reuse: {
28 enabled: true,
29 key: 'video-channel-videos-list'
30 }
31 }
32 },
33 {
34 path: 'video-playlists',
35 component: VideoChannelPlaylistsComponent,
36 data: {
37 meta: {
38 title: 'Video channel playlists'
25 } 39 }
26 } 40 }
27 }, 41 },
diff --git a/client/src/app/+video-channels/video-channels.component.html b/client/src/app/+video-channels/video-channels.component.html
index c65b5713d..600b7a365 100644
--- a/client/src/app/+video-channels/video-channels.component.html
+++ b/client/src/app/+video-channels/video-channels.component.html
@@ -22,6 +22,7 @@
22 22
23 <div class="links"> 23 <div class="links">
24 <a i18n routerLink="videos" routerLinkActive="active" class="title-page">Videos</a> 24 <a i18n routerLink="videos" routerLinkActive="active" class="title-page">Videos</a>
25 <a i18n routerLink="video-playlists" routerLinkActive="active" class="title-page">Video playlists</a>
25 <a i18n routerLink="about" routerLinkActive="active" class="title-page">About</a> 26 <a i18n routerLink="about" routerLinkActive="active" class="title-page">About</a>
26 </div> 27 </div>
27 </div> 28 </div>
diff --git a/client/src/app/+video-channels/video-channels.module.ts b/client/src/app/+video-channels/video-channels.module.ts
index a09ea6f11..6975d05b2 100644
--- a/client/src/app/+video-channels/video-channels.module.ts
+++ b/client/src/app/+video-channels/video-channels.module.ts
@@ -4,6 +4,7 @@ import { VideoChannelsRoutingModule } from './video-channels-routing.module'
4import { VideoChannelsComponent } from './video-channels.component' 4import { VideoChannelsComponent } from './video-channels.component'
5import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component' 5import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component'
6import { VideoChannelAboutComponent } from './video-channel-about/video-channel-about.component' 6import { VideoChannelAboutComponent } from './video-channel-about/video-channel-about.component'
7import { VideoChannelPlaylistsComponent } from '@app/+video-channels/video-channel-playlists/video-channel-playlists.component'
7 8
8@NgModule({ 9@NgModule({
9 imports: [ 10 imports: [
@@ -14,7 +15,8 @@ import { VideoChannelAboutComponent } from './video-channel-about/video-channel-
14 declarations: [ 15 declarations: [
15 VideoChannelsComponent, 16 VideoChannelsComponent,
16 VideoChannelVideosComponent, 17 VideoChannelVideosComponent,
17 VideoChannelAboutComponent 18 VideoChannelAboutComponent,
19 VideoChannelPlaylistsComponent
18 ], 20 ],
19 21
20 exports: [ 22 exports: [
diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts
index cff37a7d6..db8888dba 100644
--- a/client/src/app/app-routing.module.ts
+++ b/client/src/app/app-routing.module.ts
@@ -1,8 +1,9 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router' 2import { RouteReuseStrategy, RouterModule, Routes } from '@angular/router'
3 3
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'
6 7
7const routes: Routes = [ 8const routes: Routes = [
8 { 9 {
@@ -43,12 +44,14 @@ const routes: Routes = [
43 imports: [ 44 imports: [
44 RouterModule.forRoot(routes, { 45 RouterModule.forRoot(routes, {
45 useHash: Boolean(history.pushState) === false, 46 useHash: Boolean(history.pushState) === false,
47 scrollPositionRestoration: 'disabled',
46 preloadingStrategy: PreloadSelectedModulesList, 48 preloadingStrategy: PreloadSelectedModulesList,
47 anchorScrolling: 'enabled' 49 anchorScrolling: 'disabled'
48 }) 50 })
49 ], 51 ],
50 providers: [ 52 providers: [
51 PreloadSelectedModulesList 53 PreloadSelectedModulesList,
54 { provide: RouteReuseStrategy, useClass: CustomReuseStrategy }
52 ], 55 ],
53 exports: [ RouterModule ] 56 exports: [ RouterModule ]
54}) 57})
diff --git a/client/src/app/app.component.scss b/client/src/app/app.component.scss
index 881f3ff31..3f8b9777a 100644
--- a/client/src/app/app.component.scss
+++ b/client/src/app/app.component.scss
@@ -48,9 +48,8 @@
48 overflow: hidden; 48 overflow: hidden;
49 49
50 .instance-name { 50 .instance-name {
51 overflow: hidden; 51 @include ellipsis;
52 text-overflow: ellipsis; 52
53 white-space: nowrap;
54 width: 100%; 53 width: 100%;
55 } 54 }
56 55
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts
index 7583fdee8..915466af7 100644
--- a/client/src/app/app.component.ts
+++ b/client/src/app/app.component.ts
@@ -1,13 +1,14 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { DomSanitizer, SafeHtml } from '@angular/platform-browser' 2import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
3import { GuardsCheckStart, NavigationEnd, Router } 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 { skip, debounceTime } from 'rxjs/operators' 7import { debounceTime, filter, map, pairwise, skip } from 'rxjs/operators'
8import { HotkeysService, Hotkey } 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' 10import { fromEvent } from 'rxjs'
11import { ViewportScroller } from '@angular/common'
11 12
12@Component({ 13@Component({
13 selector: 'my-app', 14 selector: 'my-app',
@@ -22,6 +23,7 @@ export class AppComponent implements OnInit {
22 23
23 constructor ( 24 constructor (
24 private i18n: I18n, 25 private i18n: I18n,
26 private viewportScroller: ViewportScroller,
25 private router: Router, 27 private router: Router,
26 private authService: AuthService, 28 private authService: AuthService,
27 private serverService: ServerService, 29 private serverService: ServerService,
@@ -52,15 +54,6 @@ export class AppComponent implements OnInit {
52 ngOnInit () { 54 ngOnInit () {
53 document.getElementById('incompatible-browser').className += ' browser-ok' 55 document.getElementById('incompatible-browser').className += ' browser-ok'
54 56
55 this.router.events.subscribe(e => {
56 if (e instanceof NavigationEnd) {
57 const pathname = window.location.pathname
58 if (!pathname || pathname === '/' || is18nPath(pathname)) {
59 this.redirectService.redirectToHomepage(true)
60 }
61 }
62 })
63
64 this.authService.loadClientCredentials() 57 this.authService.loadClientCredentials()
65 58
66 if (this.isUserLoggedIn()) { 59 if (this.isUserLoggedIn()) {
@@ -74,21 +67,101 @@ export class AppComponent implements OnInit {
74 this.serverService.loadVideoLanguages() 67 this.serverService.loadVideoLanguages()
75 this.serverService.loadVideoLicences() 68 this.serverService.loadVideoLicences()
76 this.serverService.loadVideoPrivacies() 69 this.serverService.loadVideoPrivacies()
70 this.serverService.loadVideoPlaylistPrivacies()
77 71
78 // Do not display menu on small screens 72 // Do not display menu on small screens
79 if (this.screenService.isInSmallView()) { 73 if (this.screenService.isInSmallView()) {
80 this.isMenuDisplayed = false 74 this.isMenuDisplayed = false
81 } 75 }
82 76
83 this.router.events.subscribe( 77 this.initRouteEvents()
84 e => { 78 this.injectJS()
85 // User clicked on a link in the menu, change the page 79 this.injectCSS()
86 if (e instanceof GuardsCheckStart && this.screenService.isInSmallView()) { 80
87 this.isMenuDisplayed = false 81 this.initHotkeys()
88 } 82
83 fromEvent(window, 'resize')
84 .pipe(debounceTime(200))
85 .subscribe(() => this.onResize())
86 }
87
88 isUserLoggedIn () {
89 return this.authService.isLoggedIn()
90 }
91
92 toggleMenu () {
93 this.isMenuDisplayed = !this.isMenuDisplayed
94 this.isMenuChangedByUser = true
95 }
96
97 onResize () {
98 this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser
99 }
100
101 private initRouteEvents () {
102 let resetScroll = true
103 const eventsObs = this.router.events
104
105 const scrollEvent = eventsObs.pipe(filter((e: Event): e is Scroll => e instanceof Scroll))
106 const navigationEndEvent = eventsObs.pipe(filter((e: Event): e is NavigationEnd => e instanceof NavigationEnd))
107
108 scrollEvent.subscribe(e => {
109 if (e.position) {
110 return this.viewportScroller.scrollToPosition(e.position)
89 } 111 }
90 )
91 112
113 if (e.anchor) {
114 return this.viewportScroller.scrollToAnchor(e.anchor)
115 }
116
117 if (resetScroll) {
118 return this.viewportScroller.scrollToPosition([ 0, 0 ])
119 }
120 })
121
122 // When we add the a-state parameter, we don't want to alter the scroll
123 navigationEndEvent.pipe(pairwise())
124 .subscribe(([ e1, e2 ]) => {
125 try {
126 resetScroll = false
127
128 const previousUrl = new URL(window.location.origin + e1.urlAfterRedirects)
129 const nextUrl = new URL(window.location.origin + e2.urlAfterRedirects)
130
131 if (previousUrl.pathname !== nextUrl.pathname) {
132 resetScroll = true
133 return
134 }
135
136 const nextSearchParams = nextUrl.searchParams
137 nextSearchParams.delete('a-state')
138
139 const previousSearchParams = previousUrl.searchParams
140
141 nextSearchParams.sort()
142 previousSearchParams.sort()
143
144 if (nextSearchParams.toString() !== previousSearchParams.toString()) {
145 resetScroll = true
146 }
147 } catch (e) {
148 console.error('Cannot parse URL to check next scroll.', e)
149 resetScroll = true
150 }
151 })
152
153 navigationEndEvent.pipe(
154 map(() => window.location.pathname),
155 filter(pathname => !pathname || pathname === '/' || is18nPath(pathname))
156 ).subscribe(() => this.redirectService.redirectToHomepage(true))
157
158 eventsObs.pipe(
159 filter((e: Event): e is GuardsCheckStart => e instanceof GuardsCheckStart),
160 filter(() => this.screenService.isInSmallView())
161 ).subscribe(() => this.isMenuDisplayed = false) // User clicked on a link in the menu, change the page
162 }
163
164 private injectJS () {
92 // Inject JS 165 // Inject JS
93 this.serverService.configLoaded 166 this.serverService.configLoaded
94 .subscribe(() => { 167 .subscribe(() => {
@@ -103,7 +176,9 @@ export class AppComponent implements OnInit {
103 } 176 }
104 } 177 }
105 }) 178 })
179 }
106 180
181 private injectCSS () {
107 // Inject CSS if modified (admin config settings) 182 // Inject CSS if modified (admin config settings)
108 this.serverService.configLoaded 183 this.serverService.configLoaded
109 .pipe(skip(1)) // We only want to subscribe to reloads, because the CSS is already injected by the server 184 .pipe(skip(1)) // We only want to subscribe to reloads, because the CSS is already injected by the server
@@ -119,7 +194,9 @@ export class AppComponent implements OnInit {
119 this.customCSS = this.domSanitizer.bypassSecurityTrustHtml(styleTag) 194 this.customCSS = this.domSanitizer.bypassSecurityTrustHtml(styleTag)
120 } 195 }
121 }) 196 })
197 }
122 198
199 private initHotkeys () {
123 this.hotkeysService.add([ 200 this.hotkeysService.add([
124 new Hotkey(['/', 's'], (event: KeyboardEvent): boolean => { 201 new Hotkey(['/', 's'], (event: KeyboardEvent): boolean => {
125 document.getElementById('search-video').focus() 202 document.getElementById('search-video').focus()
@@ -154,22 +231,5 @@ export class AppComponent implements OnInit {
154 return false 231 return false
155 }, undefined, this.i18n('Toggle Dark theme')) 232 }, undefined, this.i18n('Toggle Dark theme'))
156 ]) 233 ])
157
158 fromEvent(window, 'resize')
159 .pipe(debounceTime(200))
160 .subscribe(() => this.onResize())
161 }
162
163 isUserLoggedIn () {
164 return this.authService.isLoggedIn()
165 }
166
167 toggleMenu () {
168 this.isMenuDisplayed = !this.isMenuDisplayed
169 this.isMenuChangedByUser = true
170 }
171
172 onResize () {
173 this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser
174 } 234 }
175} 235}
diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts
index eaa822e0f..4fc04a05c 100644
--- a/client/src/app/core/auth/auth.service.ts
+++ b/client/src/app/core/auth/auth.service.ts
@@ -153,7 +153,7 @@ export class AuthService {
153 response_type: 'code', 153 response_type: 'code',
154 grant_type: 'password', 154 grant_type: 'password',
155 scope: 'upload', 155 scope: 'upload',
156 username, 156 username: username.toLowerCase(),
157 password 157 password
158 } 158 }
159 159
diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts
index 4ef3b1e73..d3e72afb4 100644
--- a/client/src/app/core/core.module.ts
+++ b/client/src/app/core/core.module.ts
@@ -19,6 +19,7 @@ import { ToastModule } from 'primeng/toast'
19import { Notifier } from './notification' 19import { Notifier } from './notification'
20import { MessageService } from 'primeng/api' 20import { MessageService } from 'primeng/api'
21import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' 21import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
22import { ServerConfigResolver } from './routing/server-config-resolver.service'
22 23
23@NgModule({ 24@NgModule({
24 imports: [ 25 imports: [
@@ -60,7 +61,8 @@ import { UserNotificationSocket } from '@app/core/notification/user-notification
60 RedirectService, 61 RedirectService,
61 Notifier, 62 Notifier,
62 MessageService, 63 MessageService,
63 UserNotificationSocket 64 UserNotificationSocket,
65 ServerConfigResolver
64 ] 66 ]
65}) 67})
66export class CoreModule { 68export class CoreModule {
diff --git a/client/src/app/core/hotkeys/hotkeys.component.scss b/client/src/app/core/hotkeys/hotkeys.component.scss
index 9af10b7c4..3aa0b6252 100644
--- a/client/src/app/core/hotkeys/hotkeys.component.scss
+++ b/client/src/app/core/hotkeys/hotkeys.component.scss
@@ -1,5 +1,6 @@
1.cfp-hotkeys-container { 1.cfp-hotkeys-container {
2 display: table !important; 2 display: flex !important;
3 align-items: center;
3 position: fixed; 4 position: fixed;
4 overflow: auto; 5 overflow: auto;
5 width: 100%; 6 width: 100%;
@@ -35,9 +36,7 @@
35 36
36.cfp-hotkeys { 37.cfp-hotkeys {
37 width: 100%; 38 width: 100%;
38 height: 100%; 39 max-height: 100%;
39 display: table-cell;
40 vertical-align: middle;
41} 40}
42 41
43.cfp-hotkeys table { 42.cfp-hotkeys table {
@@ -102,4 +101,4 @@
102 .cfp-hotkeys { 101 .cfp-hotkeys {
103 font-size: 1.2em; 102 font-size: 1.2em;
104 } 103 }
105} \ No newline at end of file 104}
diff --git a/client/src/app/core/notification/user-notification-socket.service.ts b/client/src/app/core/notification/user-notification-socket.service.ts
index f367d9ae4..3f22da476 100644
--- a/client/src/app/core/notification/user-notification-socket.service.ts
+++ b/client/src/app/core/notification/user-notification-socket.service.ts
@@ -1,8 +1,7 @@
1import { Injectable } from '@angular/core' 1import { Injectable, NgZone } from '@angular/core'
2import { environment } from '../../../environments/environment' 2import { environment } from '../../../environments/environment'
3import { UserNotification as UserNotificationServer } from '../../../../../shared' 3import { UserNotification as UserNotificationServer } from '../../../../../shared'
4import { Subject } from 'rxjs' 4import { Subject } from 'rxjs'
5import * as io from 'socket.io-client'
6import { AuthService } from '../auth' 5import { AuthService } from '../auth'
7 6
8export type NotificationEvent = 'new' | 'read' | 'read-all' 7export type NotificationEvent = 'new' | 'read' | 'read-all'
@@ -14,28 +13,32 @@ export class UserNotificationSocket {
14 private socket: SocketIOClient.Socket 13 private socket: SocketIOClient.Socket
15 14
16 constructor ( 15 constructor (
17 private auth: AuthService 16 private auth: AuthService,
17 private ngZone: NgZone
18 ) {} 18 ) {}
19 19
20 dispatch (type: NotificationEvent, notification?: UserNotificationServer) { 20 dispatch (type: NotificationEvent, notification?: UserNotificationServer) {
21 this.notificationSubject.next({ type, notification }) 21 this.notificationSubject.next({ type, notification })
22 } 22 }
23 23
24 getMyNotificationsSocket () { 24 async getMyNotificationsSocket () {
25 const socket = this.getSocket() 25 await this.initSocket()
26
27 socket.on('new-notification', (n: UserNotificationServer) => this.dispatch('new', n))
28 26
29 return this.notificationSubject.asObservable() 27 return this.notificationSubject.asObservable()
30 } 28 }
31 29
32 private getSocket () { 30 private async initSocket () {
33 if (this.socket) return this.socket 31 if (this.socket) return
34 32
35 this.socket = io(environment.apiUrl + '/user-notifications', { 33 // FIXME: import('..') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function
36 query: { accessToken: this.auth.getAccessToken() } 34 const io: typeof import ('socket.io-client') = (await import('socket.io-client') as any).default
37 })
38 35
39 return this.socket 36 this.ngZone.runOutsideAngular(() => {
37 this.socket = io(environment.apiUrl + '/user-notifications', {
38 query: { accessToken: this.auth.getAccessToken() }
39 })
40
41 this.socket.on('new-notification', (n: UserNotificationServer) => this.dispatch('new', n))
42 })
40 } 43 }
41} 44}
diff --git a/client/src/app/core/routing/custom-reuse-strategy.ts b/client/src/app/core/routing/custom-reuse-strategy.ts
new file mode 100644
index 000000000..a9f61acec
--- /dev/null
+++ b/client/src/app/core/routing/custom-reuse-strategy.ts
@@ -0,0 +1,81 @@
1import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'
2
3export class CustomReuseStrategy implements RouteReuseStrategy {
4 storedRouteHandles = new Map<string, DetachedRouteHandle>()
5 recentlyUsed: string
6
7 private readonly MAX_SIZE = 2
8
9 // Decides if the route should be stored
10 shouldDetach (route: ActivatedRouteSnapshot): boolean {
11 return this.isReuseEnabled(route)
12 }
13
14 // Store the information for the route we're destructing
15 store (route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
16 if (!handle) return
17
18 const key = this.generateKey(route)
19 this.recentlyUsed = key
20
21 console.log('Storing component %s to reuse later.', key);
22
23 (handle as any).componentRef.instance.disableForReuse()
24
25 this.storedRouteHandles.set(key, handle)
26
27 this.gb()
28 }
29
30 // Return true if we have a stored route object for the next route
31 shouldAttach (route: ActivatedRouteSnapshot): boolean {
32 const key = this.generateKey(route)
33 return this.isReuseEnabled(route) && this.storedRouteHandles.has(key)
34 }
35
36 // If we returned true in shouldAttach(), now return the actual route data for restoration
37 retrieve (route: ActivatedRouteSnapshot): DetachedRouteHandle {
38 if (!this.isReuseEnabled(route)) return undefined
39
40 const key = this.generateKey(route)
41 this.recentlyUsed = key
42
43 console.log('Reusing component %s.', key)
44
45 const handle = this.storedRouteHandles.get(key)
46 if (!handle) return handle;
47
48 (handle as any).componentRef.instance.enabledForReuse()
49
50 return handle
51 }
52
53 // Reuse the route if we're going to and from the same route
54 shouldReuseRoute (future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
55 return future.routeConfig === curr.routeConfig
56 }
57
58 private gb () {
59 if (this.storedRouteHandles.size >= this.MAX_SIZE) {
60 this.storedRouteHandles.forEach((r, key) => {
61 if (key === this.recentlyUsed) return
62
63 console.log('Removing stored component %s.', key);
64
65 (r as any).componentRef.destroy()
66 this.storedRouteHandles.delete(key)
67 })
68 }
69 }
70
71 private generateKey (route: ActivatedRouteSnapshot) {
72 const reuse = route.data.reuse
73 if (!reuse) return undefined
74
75 return reuse.key + JSON.stringify(route.queryParams)
76 }
77
78 private isReuseEnabled (route: ActivatedRouteSnapshot) {
79 return route.data.reuse && route.data.reuse.enabled && route.queryParams['a-state']
80 }
81}
diff --git a/client/src/app/core/routing/disable-for-reuse-hook.ts b/client/src/app/core/routing/disable-for-reuse-hook.ts
new file mode 100644
index 000000000..c5eb5c578
--- /dev/null
+++ b/client/src/app/core/routing/disable-for-reuse-hook.ts
@@ -0,0 +1,7 @@
1export interface DisableForReuseHook {
2
3 disableForReuse (): void
4
5 enabledForReuse (): void
6
7}
diff --git a/client/src/app/core/routing/server-config-resolver.service.ts b/client/src/app/core/routing/server-config-resolver.service.ts
new file mode 100644
index 000000000..ec7d6428f
--- /dev/null
+++ b/client/src/app/core/routing/server-config-resolver.service.ts
@@ -0,0 +1,17 @@
1import { Injectable } from '@angular/core'
2import { Resolve } from '@angular/router'
3import { ServerService } from '@app/core/server'
4
5@Injectable()
6export class ServerConfigResolver implements Resolve<boolean> {
7 constructor (
8 private server: ServerService
9 ) {}
10
11 resolve () {
12 // FIXME: directly returning this.server.configLoaded does not seem to work
13 return new Promise<boolean>(res => {
14 return this.server.configLoaded.subscribe(() => res(true))
15 })
16 }
17}
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts
index 4ae72427b..3a8a535fd 100644
--- a/client/src/app/core/server/server.service.ts
+++ b/client/src/app/core/server/server.service.ts
@@ -4,23 +4,25 @@ import { Inject, Injectable, LOCALE_ID } from '@angular/core'
4import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage' 4import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage'
5import { Observable, of, ReplaySubject } from 'rxjs' 5import { Observable, of, ReplaySubject } from 'rxjs'
6import { getCompleteLocale, ServerConfig } from '../../../../../shared' 6import { getCompleteLocale, ServerConfig } from '../../../../../shared'
7import { About } from '../../../../../shared/models/server/about.model'
8import { environment } from '../../../environments/environment' 7import { environment } from '../../../environments/environment'
9import { VideoConstant, VideoPrivacy } from '../../../../../shared/models/videos' 8import { VideoConstant, VideoPrivacy } from '../../../../../shared/models/videos'
10import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n' 9import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
11import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' 10import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
12import { sortBy } from '@app/shared/misc/utils' 11import { sortBy } from '@app/shared/misc/utils'
12import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
13 13
14@Injectable() 14@Injectable()
15export class ServerService { 15export class ServerService {
16 private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server/' 16 private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server/'
17 private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config/' 17 private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config/'
18 private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' 18 private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
19 private static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
19 private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/' 20 private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/'
20 private static CONFIG_LOCAL_STORAGE_KEY = 'server-config' 21 private static CONFIG_LOCAL_STORAGE_KEY = 'server-config'
21 22
22 configLoaded = new ReplaySubject<boolean>(1) 23 configLoaded = new ReplaySubject<boolean>(1)
23 videoPrivaciesLoaded = new ReplaySubject<boolean>(1) 24 videoPrivaciesLoaded = new ReplaySubject<boolean>(1)
25 videoPlaylistPrivaciesLoaded = new ReplaySubject<boolean>(1)
24 videoCategoriesLoaded = new ReplaySubject<boolean>(1) 26 videoCategoriesLoaded = new ReplaySubject<boolean>(1)
25 videoLicencesLoaded = new ReplaySubject<boolean>(1) 27 videoLicencesLoaded = new ReplaySubject<boolean>(1)
26 videoLanguagesLoaded = new ReplaySubject<boolean>(1) 28 videoLanguagesLoaded = new ReplaySubject<boolean>(1)
@@ -32,6 +34,7 @@ export class ServerService {
32 shortDescription: 'PeerTube, a federated (ActivityPub) video streaming platform ' + 34 shortDescription: 'PeerTube, a federated (ActivityPub) video streaming platform ' +
33 'using P2P (BitTorrent) directly in the web browser with WebTorrent and Angular.', 35 'using P2P (BitTorrent) directly in the web browser with WebTorrent and Angular.',
34 defaultClientRoute: '', 36 defaultClientRoute: '',
37 isNSFW: false,
35 defaultNSFWPolicy: 'do_not_list' as 'do_not_list', 38 defaultNSFWPolicy: 'do_not_list' as 'do_not_list',
36 customizations: { 39 customizations: {
37 javascript: '', 40 javascript: '',
@@ -51,7 +54,10 @@ export class ServerService {
51 requiresEmailVerification: false 54 requiresEmailVerification: false
52 }, 55 },
53 transcoding: { 56 transcoding: {
54 enabledResolutions: [] 57 enabledResolutions: [],
58 hls: {
59 enabled: false
60 }
55 }, 61 },
56 avatar: { 62 avatar: {
57 file: { 63 file: {
@@ -92,12 +98,23 @@ export class ServerService {
92 videos: { 98 videos: {
93 intervalDays: 0 99 intervalDays: 0
94 } 100 }
101 },
102 autoBlacklist: {
103 videos: {
104 ofUsers: {
105 enabled: false
106 }
107 }
108 },
109 tracker: {
110 enabled: true
95 } 111 }
96 } 112 }
97 private videoCategories: Array<VideoConstant<number>> = [] 113 private videoCategories: Array<VideoConstant<number>> = []
98 private videoLicences: Array<VideoConstant<number>> = [] 114 private videoLicences: Array<VideoConstant<number>> = []
99 private videoLanguages: Array<VideoConstant<string>> = [] 115 private videoLanguages: Array<VideoConstant<string>> = []
100 private videoPrivacies: Array<VideoConstant<VideoPrivacy>> = [] 116 private videoPrivacies: Array<VideoConstant<VideoPrivacy>> = []
117 private videoPlaylistPrivacies: Array<VideoConstant<VideoPlaylistPrivacy>> = []
101 118
102 constructor ( 119 constructor (
103 private http: HttpClient, 120 private http: HttpClient,
@@ -118,19 +135,28 @@ export class ServerService {
118 } 135 }
119 136
120 loadVideoCategories () { 137 loadVideoCategories () {
121 return this.loadVideoAttributeEnum('categories', this.videoCategories, this.videoCategoriesLoaded, true) 138 return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'categories', this.videoCategories, this.videoCategoriesLoaded, true)
122 } 139 }
123 140
124 loadVideoLicences () { 141 loadVideoLicences () {
125 return this.loadVideoAttributeEnum('licences', this.videoLicences, this.videoLicencesLoaded) 142 return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'licences', this.videoLicences, this.videoLicencesLoaded)
126 } 143 }
127 144
128 loadVideoLanguages () { 145 loadVideoLanguages () {
129 return this.loadVideoAttributeEnum('languages', this.videoLanguages, this.videoLanguagesLoaded, true) 146 return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'languages', this.videoLanguages, this.videoLanguagesLoaded, true)
130 } 147 }
131 148
132 loadVideoPrivacies () { 149 loadVideoPrivacies () {
133 return this.loadVideoAttributeEnum('privacies', this.videoPrivacies, this.videoPrivaciesLoaded) 150 return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'privacies', this.videoPrivacies, this.videoPrivaciesLoaded)
151 }
152
153 loadVideoPlaylistPrivacies () {
154 return this.loadAttributeEnum(
155 ServerService.BASE_VIDEO_PLAYLIST_URL,
156 'privacies',
157 this.videoPlaylistPrivacies,
158 this.videoPlaylistPrivaciesLoaded
159 )
134 } 160 }
135 161
136 getConfig () { 162 getConfig () {
@@ -153,7 +179,12 @@ export class ServerService {
153 return this.videoPrivacies 179 return this.videoPrivacies
154 } 180 }
155 181
156 private loadVideoAttributeEnum ( 182 getVideoPlaylistPrivacies () {
183 return this.videoPlaylistPrivacies
184 }
185
186 private loadAttributeEnum (
187 baseUrl: string,
157 attributeName: 'categories' | 'licences' | 'languages' | 'privacies', 188 attributeName: 'categories' | 'licences' | 'languages' | 'privacies',
158 hashToPopulate: VideoConstant<string | number>[], 189 hashToPopulate: VideoConstant<string | number>[],
159 notifier: ReplaySubject<boolean>, 190 notifier: ReplaySubject<boolean>,
@@ -162,7 +193,7 @@ export class ServerService {
162 this.localeObservable 193 this.localeObservable
163 .pipe( 194 .pipe(
164 switchMap(translations => { 195 switchMap(translations => {
165 return this.http.get<{ [id: string]: string }>(ServerService.BASE_VIDEO_URL + attributeName) 196 return this.http.get<{ [id: string]: string }>(baseUrl + attributeName)
166 .pipe(map(data => ({ data, translations }))) 197 .pipe(map(data => ({ data, translations })))
167 }) 198 })
168 ) 199 )
diff --git a/client/src/app/login/login-routing.module.ts b/client/src/app/login/login-routing.module.ts
index 4d8913041..5a41f4e7e 100644
--- a/client/src/app/login/login-routing.module.ts
+++ b/client/src/app/login/login-routing.module.ts
@@ -1,9 +1,8 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router' 2import { RouterModule, Routes } from '@angular/router'
3
4import { MetaGuard } from '@ngx-meta/core' 3import { MetaGuard } from '@ngx-meta/core'
5
6import { LoginComponent } from './login.component' 4import { LoginComponent } from './login.component'
5import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service'
7 6
8const loginRoutes: Routes = [ 7const loginRoutes: Routes = [
9 { 8 {
@@ -14,6 +13,9 @@ const loginRoutes: Routes = [
14 meta: { 13 meta: {
15 title: 'Login' 14 title: 'Login'
16 } 15 }
16 },
17 resolve: {
18 serverConfigLoaded: ServerConfigResolver
17 } 19 }
18 } 20 }
19] 21]
diff --git a/client/src/app/menu/avatar-notification.component.html b/client/src/app/menu/avatar-notification.component.html
index 4ef3f0e89..a5ef43d42 100644
--- a/client/src/app/menu/avatar-notification.component.html
+++ b/client/src/app/menu/avatar-notification.component.html
@@ -1,6 +1,6 @@
1<div 1<div
2 [ngbPopover]="popContent" autoClose="outside" placement="bottom-left" container="body" popoverClass="popover-notifications" 2 [ngbPopover]="popContent" autoClose="outside" placement="bottom-left" container="body" popoverClass="popover-notifications"
3 i18n-title title="View your notifications" class="notification-avatar" #popover="ngbPopover" 3 i18n-title title="View your notifications" class="notification-avatar" #popover="ngbPopover" (hidden)="onPopoverHidden()"
4> 4>
5 <div *ngIf="unreadNotifications > 0" class="unread-notifications">{{ unreadNotifications }}</div> 5 <div *ngIf="unreadNotifications > 0" class="unread-notifications">{{ unreadNotifications }}</div>
6 6
@@ -8,16 +8,25 @@
8</div> 8</div>
9 9
10<ng-template #popContent> 10<ng-template #popContent>
11 <div class="notifications-header"> 11 <div class="content" [ngClass]="{ loaded: loaded }">
12 <div i18n>Notifications</div> 12 <div class="notifications-header">
13 <div i18n>Notifications</div>
13 14
14 <a 15 <a
15 i18n-title title="Update your notification preferences" class="glyphicon glyphicon-cog" 16 i18n-title title="Update your notification preferences" class="glyphicon glyphicon-cog"
16 routerLink="/my-account/settings" fragment="notifications" 17 routerLink="/my-account/settings" fragment="notifications"
17 ></a> 18 ></a>
18 </div> 19 </div>
20
21 <div *ngIf="!loaded" class="loader">
22 <my-loader [loading]="!loaded"></my-loader>
23 </div>
19 24
20 <my-user-notifications [ignoreLoadingBar]="true" [infiniteScroll]="false" itemsPerPage="10"></my-user-notifications> 25 <my-user-notifications
26 [ignoreLoadingBar]="true" [infiniteScroll]="false" itemsPerPage="10"
27 (notificationsLoaded)="onNotificationLoaded()"
28 ></my-user-notifications>
21 29
22 <a class="all-notifications" routerLink="/my-account/notifications" i18n>See all your notifications</a> 30 <a *ngIf="loaded" class="all-notifications" routerLink="/my-account/notifications" i18n>See all your notifications</a>
31 </div>
23</ng-template> 32</ng-template>
diff --git a/client/src/app/menu/avatar-notification.component.scss b/client/src/app/menu/avatar-notification.component.scss
index e785db788..201668b6e 100644
--- a/client/src/app/menu/avatar-notification.component.scss
+++ b/client/src/app/menu/avatar-notification.component.scss
@@ -9,11 +9,27 @@
9 padding: 0; 9 padding: 0;
10 font-size: 14px; 10 font-size: 14px;
11 font-family: $main-fonts; 11 font-family: $main-fonts;
12 overflow-y: auto; 12 overflow-y: scroll;
13 max-height: 500px;
14 width: 400px; 13 width: 400px;
15 box-shadow: 0 6px 14px rgba(0, 0, 0, 0.30); 14 box-shadow: 0 6px 14px rgba(0, 0, 0, 0.30);
16 15
16 .loader {
17 display: flex;
18 align-items: center;
19 justify-content: center;
20
21 padding: 5px 0;
22 }
23
24 .content {
25 max-height: 150px;
26 transition: max-height 0.15s ease-out;
27
28 &.loaded {
29 max-height: 500px;
30 }
31 }
32
17 .notifications-header { 33 .notifications-header {
18 display: flex; 34 display: flex;
19 justify-content: space-between; 35 justify-content: space-between;
diff --git a/client/src/app/menu/avatar-notification.component.ts b/client/src/app/menu/avatar-notification.component.ts
index f1af08096..a77a001ca 100644
--- a/client/src/app/menu/avatar-notification.component.ts
+++ b/client/src/app/menu/avatar-notification.component.ts
@@ -17,6 +17,7 @@ export class AvatarNotificationComponent implements OnInit, OnDestroy {
17 @Input() user: User 17 @Input() user: User
18 18
19 unreadNotifications = 0 19 unreadNotifications = 0
20 loaded = false
20 21
21 private notificationSub: Subscription 22 private notificationSub: Subscription
22 private routeSub: Subscription 23 private routeSub: Subscription
@@ -26,18 +27,19 @@ export class AvatarNotificationComponent implements OnInit, OnDestroy {
26 private userNotificationSocket: UserNotificationSocket, 27 private userNotificationSocket: UserNotificationSocket,
27 private notifier: Notifier, 28 private notifier: Notifier,
28 private router: Router 29 private router: Router
29 ) {} 30 ) {
31 }
30 32
31 ngOnInit () { 33 ngOnInit () {
32 this.userNotificationService.countUnreadNotifications() 34 this.userNotificationService.countUnreadNotifications()
33 .subscribe( 35 .subscribe(
34 result => { 36 result => {
35 this.unreadNotifications = Math.min(result, 99) // Limit number to 99 37 this.unreadNotifications = Math.min(result, 99) // Limit number to 99
36 this.subscribeToNotifications() 38 this.subscribeToNotifications()
37 }, 39 },
38 40
39 err => this.notifier.error(err.message) 41 err => this.notifier.error(err.message)
40 ) 42 )
41 43
42 this.routeSub = this.router.events 44 this.routeSub = this.router.events
43 .pipe(filter(event => event instanceof NavigationEnd)) 45 .pipe(filter(event => event instanceof NavigationEnd))
@@ -53,13 +55,22 @@ export class AvatarNotificationComponent implements OnInit, OnDestroy {
53 this.popover.close() 55 this.popover.close()
54 } 56 }
55 57
56 private subscribeToNotifications () { 58 onPopoverHidden () {
57 this.notificationSub = this.userNotificationSocket.getMyNotificationsSocket() 59 this.loaded = false
58 .subscribe(data => { 60 }
59 if (data.type === 'new') return this.unreadNotifications++ 61
60 if (data.type === 'read') return this.unreadNotifications-- 62 onNotificationLoaded () {
61 if (data.type === 'read-all') return this.unreadNotifications = 0 63 this.loaded = true
62 }) 64 }
65
66 private async subscribeToNotifications () {
67 const obs = await this.userNotificationSocket.getMyNotificationsSocket()
68
69 this.notificationSub = obs.subscribe(data => {
70 if (data.type === 'new') return this.unreadNotifications++
71 if (data.type === 'read') return this.unreadNotifications--
72 if (data.type === 'read-all') return this.unreadNotifications = 0
73 })
63 } 74 }
64 75
65} 76}
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html
index 1e532ec13..e80e6b803 100644
--- a/client/src/app/menu/menu.component.html
+++ b/client/src/app/menu/menu.component.html
@@ -1,5 +1,5 @@
1<div class="menu-wrapper"> 1<div class="menu-wrapper">
2 <menu> 2 <menu [ngClass]="{ 'logged-in': isLoggedIn }">
3 <div class="top-menu"> 3 <div class="top-menu">
4 <div *ngIf="isLoggedIn" class="logged-in-block"> 4 <div *ngIf="isLoggedIn" class="logged-in-block">
5 <my-avatar-notification [user]="user"></my-avatar-notification> 5 <my-avatar-notification [user]="user"></my-avatar-notification>
@@ -10,23 +10,19 @@
10 </div> 10 </div>
11 11
12 <div class="logged-in-more" ngbDropdown placement="bottom-right"> 12 <div class="logged-in-more" ngbDropdown placement="bottom-right">
13 <span class="glyphicon glyphicon-option-vertical" ngbDropdownToggle role="button"></span> 13 <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button"></my-global-icon>
14 14
15 <div ngbDropdownMenu> 15 <div ngbDropdownMenu>
16 <a *ngIf="user.account" i18n [routerLink]="[ '/accounts', user.account.nameWithHost ]" class="dropdown-item"> 16 <a *ngIf="user.account" [routerLink]="[ '/accounts', user.account.nameWithHost ]" class="dropdown-item">
17 My public profile 17 <my-global-icon iconName="go"></my-global-icon> <ng-container i18n>My public profile</ng-container>
18 </a> 18 </a>
19 19
20 <a i18n routerLink="/my-account" class="dropdown-item"> 20 <a routerLink="/my-account" class="dropdown-item">
21 My account 21 <my-global-icon iconName="user"></my-global-icon> <ng-container i18n>My account</ng-container>
22 </a> 22 </a>
23 23
24 <a i18n routerLink="/my-account/videos" class="dropdown-item"> 24 <a (click)="logout($event)" class="dropdown-item" href="#">
25 My videos 25 <my-global-icon iconName="sign-out"></my-global-icon> <ng-container i18n>Log out</ng-container>
26 </a>
27
28 <a i18n (click)="logout($event)" class="dropdown-item" href="#">
29 Log out
30 </a> 26 </a>
31 </div> 27 </div>
32 </div> 28 </div>
@@ -37,31 +33,51 @@
37 <a i18n *ngIf="isRegistrationAllowed()" routerLink="/signup" class="create-account-button">Create an account</a> 33 <a i18n *ngIf="isRegistrationAllowed()" routerLink="/signup" class="create-account-button">Create an account</a>
38 </div> 34 </div>
39 35
40 <div class="panel-block"> 36 <div *ngIf="isLoggedIn" class="panel-block">
41 <div i18n class="block-title">Videos</div> 37 <div i18n class="block-title">My library</div>
38
39 <a routerLink="/my-account/videos" routerLinkActive="active">
40 <my-global-icon iconName="videos"></my-global-icon>
41 <ng-container i18n>Videos</ng-container>
42 </a>
43
44 <a routerLink="/my-account/video-playlists" routerLinkActive="active">
45 <my-global-icon iconName="playlists"></my-global-icon>
46 <ng-container i18n>Playlists</ng-container>
47 </a>
42 48
43 <a *ngIf="isLoggedIn" routerLink="/videos/subscriptions" routerLinkActive="active"> 49 <a routerLink="/videos/subscriptions" routerLinkActive="active">
44 <span class="icon icon-videos-subscriptions"></span> 50 <my-global-icon iconName="subscriptions"></my-global-icon>
45 <ng-container i18n>Subscriptions</ng-container> 51 <ng-container i18n>Subscriptions</ng-container>
46 </a> 52 </a>
47 53
54 <a routerLink="/my-account/history/videos" routerLinkActive="active">
55 <my-global-icon iconName="history"></my-global-icon>
56 <ng-container i18n>History</ng-container>
57 </a>
58
59 </div>
60
61 <div class="panel-block">
62 <div i18n class="block-title">Videos</div>
63
48 <a routerLink="/videos/overview" routerLinkActive="active"> 64 <a routerLink="/videos/overview" routerLinkActive="active">
49 <span class="icon icon-videos-overview"></span> 65 <my-global-icon iconName="globe"></my-global-icon>
50 <ng-container i18n>Overview</ng-container> 66 <ng-container i18n>Overview</ng-container>
51 </a> 67 </a>
52 68
53 <a routerLink="/videos/trending" routerLinkActive="active"> 69 <a routerLink="/videos/trending" routerLinkActive="active">
54 <span class="icon icon-videos-trending"></span> 70 <my-global-icon iconName="trending"></my-global-icon>
55 <ng-container i18n>Trending</ng-container> 71 <ng-container i18n>Trending</ng-container>
56 </a> 72 </a>
57 73
58 <a routerLink="/videos/recently-added" routerLinkActive="active"> 74 <a routerLink="/videos/recently-added" routerLinkActive="active">
59 <span class="icon icon-videos-recently-added"></span> 75 <my-global-icon iconName="recently-added"></my-global-icon>
60 <ng-container i18n>Recently added</ng-container> 76 <ng-container i18n>Recently added</ng-container>
61 </a> 77 </a>
62 78
63 <a routerLink="/videos/local" routerLinkActive="active"> 79 <a routerLink="/videos/local" routerLinkActive="active">
64 <span class="icon icon-videos-local"></span> 80 <my-global-icon iconName="home"></my-global-icon>
65 <ng-container i18n>Local</ng-container> 81 <ng-container i18n>Local</ng-container>
66 </a> 82 </a>
67 </div> 83 </div>
@@ -70,12 +86,12 @@
70 <div class="block-title" i18n>More</div> 86 <div class="block-title" i18n>More</div>
71 87
72 <a *ngIf="userHasAdminAccess" [routerLink]="getFirstAdminRouteAvailable()" routerLinkActive="active"> 88 <a *ngIf="userHasAdminAccess" [routerLink]="getFirstAdminRouteAvailable()" routerLinkActive="active">
73 <span class="icon icon-administration"></span> 89 <my-global-icon iconName="administration"></my-global-icon>
74 <ng-container i18n>Administration</ng-container> 90 <ng-container i18n>Administration</ng-container>
75 </a> 91 </a>
76 92
77 <a routerLink="/about" routerLinkActive="active"> 93 <a routerLink="/about" routerLinkActive="active">
78 <span class="icon icon-about"></span> 94 <my-global-icon iconName="about"></my-global-icon>
79 <ng-container i18n>About</ng-container> 95 <ng-container i18n>About</ng-container>
80 </a> 96 </a>
81 </div> 97 </div>
diff --git a/client/src/app/menu/menu.component.scss b/client/src/app/menu/menu.component.scss
index 69704674a..2f23fa5f8 100644
--- a/client/src/app/menu/menu.component.scss
+++ b/client/src/app/menu/menu.component.scss
@@ -10,12 +10,12 @@
10} 10}
11 11
12menu { 12menu {
13 @include ellipsis;
14
13 background-color: var(--menuBackgroundColor); 15 background-color: var(--menuBackgroundColor);
14 margin: 0; 16 margin: 0;
15 padding: 0; 17 padding: 0;
16 height: 100%; 18 height: 100%;
17 white-space: nowrap;
18 text-overflow: ellipsis;
19 overflow: auto; 19 overflow: auto;
20 color: var(--menuForegroundColor); 20 color: var(--menuForegroundColor);
21 display: flex; 21 display: flex;
@@ -26,6 +26,16 @@ menu {
26 overflow-y: auto; 26 overflow-y: auto;
27 } 27 }
28 28
29 &.logged-in {
30 .panel-block {
31 margin-bottom: 25px;
32 }
33
34 .block-title {
35 margin-bottom: 15px;
36 }
37 }
38
29 .top-menu { 39 .top-menu {
30 flex-grow: 1; 40 flex-grow: 1;
31 width: $menu-width; 41 width: $menu-width;
@@ -37,9 +47,11 @@ menu {
37 display: flex; 47 display: flex;
38 align-items: center; 48 align-items: center;
39 justify-content: center; 49 justify-content: center;
40 margin-bottom: 35px; 50 margin-bottom: 20px;
41 51
42 .logged-in-info { 52 .logged-in-info {
53 @include ellipsis;
54
43 flex-grow: 1; 55 flex-grow: 1;
44 white-space: nowrap; 56 white-space: nowrap;
45 overflow: hidden; 57 overflow: hidden;
@@ -55,11 +67,10 @@ menu {
55 } 67 }
56 68
57 .logged-in-username { 69 .logged-in-username {
70 @include ellipsis;
71
58 font-size: 13px; 72 font-size: 13px;
59 color: #C6C6C6; 73 color: #C6C6C6;
60 white-space: nowrap;
61 overflow: hidden;
62 text-overflow: ellipsis;
63 max-width: 140px; 74 max-width: 140px;
64 } 75 }
65 } 76 }
@@ -67,14 +78,33 @@ menu {
67 .logged-in-more { 78 .logged-in-more {
68 margin-right: 20px; 79 margin-right: 20px;
69 80
70 .glyphicon { 81 my-global-icon {
82 @include apply-svg-color(var(--mainBackgroundColor));
83
71 cursor: pointer; 84 cursor: pointer;
72 font-size: 18px;
73 85
74 &::after { 86 &::after {
75 border: none; 87 border: none;
76 } 88 }
77 } 89 }
90
91 .dropdown-item {
92 @include dropdown-with-icon-item;
93
94 my-global-icon {
95 @include apply-svg-color(var(--mainForegroundColor));
96
97 width: 22px;
98 height: 22px;
99
100 &[iconName="sign-out"] {
101 position: relative;
102 right: -1px;
103 height: 21px;
104 width: 21px;
105 }
106 }
107 }
78 } 108 }
79 } 109 }
80 110
@@ -135,57 +165,31 @@ menu {
135 background-color: rgba(255, 255, 255, 0.10); 165 background-color: rgba(255, 255, 255, 0.10);
136 } 166 }
137 167
138 .icon { 168 my-global-icon {
139 @include icon(22px); 169 @include apply-svg-color(#808080);
140 170
171 display: flex;
172 width: 22px;
173 height: 22px;
141 margin-right: 18px; 174 margin-right: 18px;
142 175
143 &.icon-videos-subscriptions { 176 &[iconName="playlists"] {
144 position: relative; 177 height: 24px;
145 top: -1px; 178 width: 24px;
146 background-image: url('../../assets/images/menu/subscriptions.svg');
147 }
148
149 &.icon-videos-overview {
150 position: relative;
151 background-image: url('../../assets/images/menu/globe.svg');
152 }
153
154 &.icon-videos-trending {
155 position: relative;
156 top: -1px;
157 background-image: url('../../assets/images/menu/trending.svg');
158 }
159 179
160 &.icon-videos-recently-added { 180 margin-right: 16px;
161 width: 23px;
162 height: 23px;
163 background-image: url('../../assets/images/menu/recently-added.svg');
164 } 181 }
165 182
166 &.icon-videos-local { 183 &[iconName="videos"] {
167 width: 23px;
168 height: 23px;
169
170 position: relative; 184 position: relative;
171 top: -1px; 185 right: -1px;
172
173 background-image: url('../../assets/images/menu/home.svg');
174 }
175
176 &.icon-administration {
177 width: 23px;
178 height: 23px;
179
180 background-image: url('../../assets/images/menu/administration.svg');
181 } 186 }
187 }
182 188
183 &.icon-about { 189 .icon {
184 width: 23px; 190 @include icon(22px);
185 height: 23px;
186 191
187 background-image: url('../../assets/images/menu/about.svg'); 192 margin-right: 18px;
188 }
189 } 193 }
190 } 194 }
191 } 195 }
@@ -224,7 +228,6 @@ menu {
224 height: 24px; 228 height: 24px;
225 229
226 background-image: url('../../assets/images/menu/keyboard.png'); 230 background-image: url('../../assets/images/menu/keyboard.png');
227 background-color: #fff;
228 filter: invert(100%); 231 filter: invert(100%);
229 } 232 }
230 233
diff --git a/client/src/app/search/advanced-search.model.ts b/client/src/app/search/advanced-search.model.ts
index 033fa9bba..5b713e145 100644
--- a/client/src/app/search/advanced-search.model.ts
+++ b/client/src/app/search/advanced-search.model.ts
@@ -4,6 +4,9 @@ export class AdvancedSearch {
4 startDate: string // ISO 8601 4 startDate: string // ISO 8601
5 endDate: string // ISO 8601 5 endDate: string // ISO 8601
6 6
7 originallyPublishedStartDate: string // ISO 8601
8 originallyPublishedEndDate: string // ISO 8601
9
7 nsfw: NSFWQuery 10 nsfw: NSFWQuery
8 11
9 categoryOneOf: string 12 categoryOneOf: string
@@ -23,6 +26,8 @@ export class AdvancedSearch {
23 constructor (options?: { 26 constructor (options?: {
24 startDate?: string 27 startDate?: string
25 endDate?: string 28 endDate?: string
29 originallyPublishedStartDate?: string
30 originallyPublishedEndDate?: string
26 nsfw?: NSFWQuery 31 nsfw?: NSFWQuery
27 categoryOneOf?: string 32 categoryOneOf?: string
28 licenceOneOf?: string 33 licenceOneOf?: string
@@ -37,6 +42,9 @@ export class AdvancedSearch {
37 42
38 this.startDate = options.startDate || undefined 43 this.startDate = options.startDate || undefined
39 this.endDate = options.endDate || undefined 44 this.endDate = options.endDate || undefined
45 this.originallyPublishedStartDate = options.originallyPublishedStartDate || undefined
46 this.originallyPublishedEndDate = options.originallyPublishedEndDate || undefined
47
40 this.nsfw = options.nsfw || undefined 48 this.nsfw = options.nsfw || undefined
41 this.categoryOneOf = options.categoryOneOf || undefined 49 this.categoryOneOf = options.categoryOneOf || undefined
42 this.licenceOneOf = options.licenceOneOf || undefined 50 this.licenceOneOf = options.licenceOneOf || undefined
@@ -66,6 +74,8 @@ export class AdvancedSearch {
66 reset () { 74 reset () {
67 this.startDate = undefined 75 this.startDate = undefined
68 this.endDate = undefined 76 this.endDate = undefined
77 this.originallyPublishedStartDate = undefined
78 this.originallyPublishedEndDate = undefined
69 this.nsfw = undefined 79 this.nsfw = undefined
70 this.categoryOneOf = undefined 80 this.categoryOneOf = undefined
71 this.licenceOneOf = undefined 81 this.licenceOneOf = undefined
@@ -82,6 +92,8 @@ export class AdvancedSearch {
82 return { 92 return {
83 startDate: this.startDate, 93 startDate: this.startDate,
84 endDate: this.endDate, 94 endDate: this.endDate,
95 originallyPublishedStartDate: this.originallyPublishedStartDate,
96 originallyPublishedEndDate: this.originallyPublishedEndDate,
85 nsfw: this.nsfw, 97 nsfw: this.nsfw,
86 categoryOneOf: this.categoryOneOf, 98 categoryOneOf: this.categoryOneOf,
87 licenceOneOf: this.licenceOneOf, 99 licenceOneOf: this.licenceOneOf,
@@ -98,6 +110,8 @@ export class AdvancedSearch {
98 return { 110 return {
99 startDate: this.startDate, 111 startDate: this.startDate,
100 endDate: this.endDate, 112 endDate: this.endDate,
113 originallyPublishedStartDate: this.originallyPublishedStartDate,
114 originallyPublishedEndDate: this.originallyPublishedEndDate,
101 nsfw: this.nsfw, 115 nsfw: this.nsfw,
102 categoryOneOf: this.intoArray(this.categoryOneOf), 116 categoryOneOf: this.intoArray(this.categoryOneOf),
103 licenceOneOf: this.intoArray(this.licenceOneOf), 117 licenceOneOf: this.intoArray(this.licenceOneOf),
diff --git a/client/src/app/search/search-filters.component.html b/client/src/app/search/search-filters.component.html
index 74bb781f4..8220a990b 100644
--- a/client/src/app/search/search-filters.component.html
+++ b/client/src/app/search/search-filters.component.html
@@ -21,6 +21,27 @@
21 </div> 21 </div>
22 22
23 <div class="form-group"> 23 <div class="form-group">
24 <label i18n for="original-publication-after">Original publication year</label>
25
26 <div class="row">
27 <div class="col-sm-6">
28 <input
29 type="text" id="original-publication-after" name="original-publication-after"
30 i18n-placeholder placeholder="After..."
31 [(ngModel)]="originallyPublishedStartYear"
32 >
33 </div>
34 <div class="col-sm-6">
35 <input
36 type="text" id="original-publication-before" name="original-publication-before"
37 i18n-placeholder placeholder="Before..."
38 [(ngModel)]="originallyPublishedEndYear"
39 >
40 </div>
41 </div>
42 </div>
43
44 <div class="form-group">
24 <div i18n class="radio-label">Duration</div> 45 <div i18n class="radio-label">Duration</div>
25 46
26 <div class="peertube-radio-container" *ngFor="let duration of durationRanges"> 47 <div class="peertube-radio-container" *ngFor="let duration of durationRanges">
@@ -93,4 +114,4 @@
93 <div class="submit-button"> 114 <div class="submit-button">
94 <input type="submit" i18n-value value="Filter"> 115 <input type="submit" i18n-value value="Filter">
95 </div> 116 </div>
96</form> \ No newline at end of file 117</form>
diff --git a/client/src/app/search/search-filters.component.ts b/client/src/app/search/search-filters.component.ts
index 3fdc6df35..762a6b7f2 100644
--- a/client/src/app/search/search-filters.component.ts
+++ b/client/src/app/search/search-filters.component.ts
@@ -25,6 +25,9 @@ export class SearchFiltersComponent implements OnInit {
25 publishedDateRange: string 25 publishedDateRange: string
26 durationRange: string 26 durationRange: string
27 27
28 originallyPublishedStartYear: string
29 originallyPublishedEndYear: string
30
28 constructor ( 31 constructor (
29 private i18n: I18n, 32 private i18n: I18n,
30 private serverService: ServerService 33 private serverService: ServerService
@@ -86,15 +89,27 @@ export class SearchFiltersComponent implements OnInit {
86 89
87 this.loadFromDurationRange() 90 this.loadFromDurationRange()
88 this.loadFromPublishedRange() 91 this.loadFromPublishedRange()
92 this.loadOriginallyPublishedAtYears()
89 } 93 }
90 94
91 formUpdated () { 95 formUpdated () {
92 this.updateModelFromDurationRange() 96 this.updateModelFromDurationRange()
93 this.updateModelFromPublishedRange() 97 this.updateModelFromPublishedRange()
98 this.updateModelFromOriginallyPublishedAtYears()
94 99
95 this.filtered.emit(this.advancedSearch) 100 this.filtered.emit(this.advancedSearch)
96 } 101 }
97 102
103 private loadOriginallyPublishedAtYears () {
104 this.originallyPublishedStartYear = this.advancedSearch.originallyPublishedStartDate
105 ? new Date(this.advancedSearch.originallyPublishedStartDate).getFullYear().toString()
106 : null
107
108 this.originallyPublishedEndYear = this.advancedSearch.originallyPublishedEndDate
109 ? new Date(this.advancedSearch.originallyPublishedEndDate).getFullYear().toString()
110 : null
111 }
112
98 private loadFromDurationRange () { 113 private loadFromDurationRange () {
99 if (this.advancedSearch.durationMin || this.advancedSearch.durationMax) { 114 if (this.advancedSearch.durationMin || this.advancedSearch.durationMax) {
100 const fourMinutes = 60 * 4 115 const fourMinutes = 60 * 4
@@ -127,6 +142,32 @@ export class SearchFiltersComponent implements OnInit {
127 } 142 }
128 } 143 }
129 144
145 private updateModelFromOriginallyPublishedAtYears () {
146 const baseDate = new Date()
147 baseDate.setHours(0, 0, 0, 0)
148 baseDate.setMonth(0, 1)
149
150 if (this.originallyPublishedStartYear) {
151 const year = parseInt(this.originallyPublishedStartYear, 10)
152 const start = new Date(baseDate)
153 start.setFullYear(year)
154
155 this.advancedSearch.originallyPublishedStartDate = start.toISOString()
156 } else {
157 this.advancedSearch.originallyPublishedStartDate = null
158 }
159
160 if (this.originallyPublishedEndYear) {
161 const year = parseInt(this.originallyPublishedEndYear, 10)
162 const end = new Date(baseDate)
163 end.setFullYear(year)
164
165 this.advancedSearch.originallyPublishedEndDate = end.toISOString()
166 } else {
167 this.advancedSearch.originallyPublishedEndDate = null
168 }
169 }
170
130 private updateModelFromDurationRange () { 171 private updateModelFromDurationRange () {
131 if (!this.durationRange) return 172 if (!this.durationRange) return
132 173
@@ -174,4 +215,5 @@ export class SearchFiltersComponent implements OnInit {
174 215
175 this.advancedSearch.startDate = date.toISOString() 216 this.advancedSearch.startDate = date.toISOString()
176 } 217 }
218
177} 219}
diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html
index 82a5f0f26..0a9f78cb2 100644
--- a/client/src/app/search/search.component.html
+++ b/client/src/app/search/search.component.html
@@ -48,13 +48,10 @@
48 </div> 48 </div>
49 49
50 <div *ngIf="isVideo(result)" class="entry video"> 50 <div *ngIf="isVideo(result)" class="entry video">
51 <my-video-thumbnail [video]="result" [nsfw]="isVideoBlur(result)"></my-video-thumbnail> 51 <my-video-miniature
52 52 [video]="result" [user]="user" [displayAsRow]="true"
53 <div class="video-info"> 53 (videoBlacklisted)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)"
54 <a tabindex="-1" class="video-info-name" [routerLink]="['/videos/watch', result.uuid]" [attr.title]="result.name">{{ result.name }}</a> 54 ></my-video-miniature>
55 <span i18n class="video-info-date-views">{{ result.publishedAt | myFromNow }} - {{ result.views | myNumberFormatter }} views</span>
56 <a tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', result.byAccount ]">{{ result.byAccount }}</a>
57 </div>
58 </div> 55 </div>
59 </ng-container> 56 </ng-container>
60 57
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss
index 6de13d276..4e3ce1c96 100644
--- a/client/src/app/search/search.component.scss
+++ b/client/src/app/search/search.component.scss
@@ -55,53 +55,14 @@
55 padding-bottom: 20px; 55 padding-bottom: 20px;
56 margin-bottom: 20px; 56 margin-bottom: 20px;
57 57
58 &.video {
59
60 my-video-thumbnail {
61 margin-right: 10px;
62 }
63
64 .video-info {
65 flex-grow: 1;
66
67 .video-info-name {
68 @include disable-default-a-behaviour;
69
70 color: var(--mainForegroundColor);
71 display: block;
72 width: fit-content;
73 font-size: 18px;
74 font-weight: $font-semibold;
75 }
76
77 .video-info-date-views {
78 font-size: 14px;
79 }
80
81 .video-info-account {
82 @include disable-default-a-behaviour;
83
84 display: block;
85 width: fit-content;
86 overflow: hidden;
87 text-overflow: ellipsis;
88 white-space: nowrap;
89 font-size: 14px;
90 color: $grey-foreground-color;
91
92 &:hover {
93 color: $grey-foreground-hover-color;
94 }
95 }
96 }
97 }
98
99 &.video-channel { 58 &.video-channel {
100
101 img { 59 img {
102 @include avatar(120px); 60 $image-size: 130px;
61 $margin-size: ($video-thumbnail-width - $image-size) / 2; // So we have the same width than the video miniature
62
63 @include avatar($image-size);
103 64
104 margin: 0 50px 0 40px; 65 margin: 0 ($margin-size + 10) 0 $margin-size;
105 } 66 }
106 67
107 .video-channel-info { 68 .video-channel-info {
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts
index c4a4b1fde..a7ddbe1f8 100644
--- a/client/src/app/search/search.component.ts
+++ b/client/src/app/search/search.component.ts
@@ -1,6 +1,6 @@
1import { Component, OnDestroy, OnInit } from '@angular/core' 1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { AuthService, Notifier, ServerService } from '@app/core' 3import { AuthService, Notifier } from '@app/core'
4import { forkJoin, Subscription } from 'rxjs' 4import { forkJoin, Subscription } from 'rxjs'
5import { SearchService } from '@app/search/search.service' 5import { SearchService } from '@app/search/search.service'
6import { ComponentPagination } from '@app/shared/rest/component-pagination.model' 6import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
@@ -41,10 +41,13 @@ export class SearchComponent implements OnInit, OnDestroy {
41 private metaService: MetaService, 41 private metaService: MetaService,
42 private notifier: Notifier, 42 private notifier: Notifier,
43 private searchService: SearchService, 43 private searchService: SearchService,
44 private authService: AuthService, 44 private authService: AuthService
45 private serverService: ServerService
46 ) { } 45 ) { }
47 46
47 get user () {
48 return this.authService.getUser()
49 }
50
48 ngOnInit () { 51 ngOnInit () {
49 this.subActivatedRoute = this.route.queryParams.subscribe( 52 this.subActivatedRoute = this.route.queryParams.subscribe(
50 queryParams => { 53 queryParams => {
@@ -76,10 +79,6 @@ export class SearchComponent implements OnInit, OnDestroy {
76 if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe() 79 if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
77 } 80 }
78 81
79 isVideoBlur (video: Video) {
80 return video.isVideoNSFWForUser(this.authService.getUser(), this.serverService.getConfig())
81 }
82
83 isVideoChannel (d: VideoChannel | Video): d is VideoChannel { 82 isVideoChannel (d: VideoChannel | Video): d is VideoChannel {
84 return d instanceof VideoChannel 83 return d instanceof VideoChannel
85 } 84 }
@@ -139,6 +138,10 @@ export class SearchComponent implements OnInit, OnDestroy {
139 return this.advancedSearch.size() 138 return this.advancedSearch.size()
140 } 139 }
141 140
141 removeVideoFromArray (video: Video) {
142 this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
143 }
144
142 private resetPagination () { 145 private resetPagination () {
143 this.pagination.currentPage = 1 146 this.pagination.currentPage = 1
144 this.pagination.totalItems = null 147 this.pagination.totalItems = null
diff --git a/client/src/app/shared/misc/from-now.pipe.ts b/client/src/app/shared/angular/from-now.pipe.ts
index 00b5be6c9..3a9a76411 100644
--- a/client/src/app/shared/misc/from-now.pipe.ts
+++ b/client/src/app/shared/angular/from-now.pipe.ts
@@ -35,6 +35,6 @@ export class FromNowPipe implements PipeTransform {
35 interval = Math.floor(seconds / 60) 35 interval = Math.floor(seconds / 60)
36 if (interval >= 1) return this.i18n('{{interval}} min ago', { interval }) 36 if (interval >= 1) return this.i18n('{{interval}} min ago', { interval })
37 37
38 return this.i18n('{{interval}} sec ago', { interval: Math.floor(seconds) }) 38 return this.i18n('{{interval}} sec ago', { interval: Math.max(0, seconds) })
39 } 39 }
40} 40}
diff --git a/client/src/app/shared/misc/number-formatter.pipe.ts b/client/src/app/shared/angular/number-formatter.pipe.ts
index 8a0756a36..8a0756a36 100644
--- a/client/src/app/shared/misc/number-formatter.pipe.ts
+++ b/client/src/app/shared/angular/number-formatter.pipe.ts
diff --git a/client/src/app/shared/misc/object-length.pipe.ts b/client/src/app/shared/angular/object-length.pipe.ts
index 84d182052..84d182052 100644
--- a/client/src/app/shared/misc/object-length.pipe.ts
+++ b/client/src/app/shared/angular/object-length.pipe.ts
diff --git a/client/src/app/shared/angular/peertube-template.directive.ts b/client/src/app/shared/angular/peertube-template.directive.ts
new file mode 100644
index 000000000..a514b6057
--- /dev/null
+++ b/client/src/app/shared/angular/peertube-template.directive.ts
@@ -0,0 +1,12 @@
1import { Directive, Input, TemplateRef } from '@angular/core'
2
3@Directive({
4 selector: '[ptTemplate]'
5})
6export class PeerTubeTemplateDirective {
7 @Input('ptTemplate') name: string
8
9 constructor (public template: TemplateRef<any>) {
10 // empty
11 }
12}
diff --git a/client/src/app/shared/buttons/action-dropdown.component.html b/client/src/app/shared/buttons/action-dropdown.component.html
index 114b1d71f..cc244dc76 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.html
+++ b/client/src/app/shared/buttons/action-dropdown.component.html
@@ -1,9 +1,11 @@
1<div class="dropdown-root" ngbDropdown [placement]="placement"> 1<div class="dropdown-root" ngbDropdown [placement]="placement">
2 <div 2 <div
3 class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange' }" 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"
5 > 5 >
6 <my-global-icon *ngIf="!label" class="more-icon" iconName="more"></my-global-icon> 6 <my-global-icon *ngIf="!label && buttonDirection === 'horizontal'" class="more-icon" iconName="more-horizontal"></my-global-icon>
7 <my-global-icon *ngIf="!label && buttonDirection === 'vertical'" class="more-icon" iconName="more-vertical"></my-global-icon>
8
7 <span *ngIf="label" class="dropdown-toggle">{{ label }}</span> 9 <span *ngIf="label" class="dropdown-toggle">{{ label }}</span>
8 </div> 10 </div>
9 11
@@ -12,15 +14,24 @@
12 14
13 <ng-container *ngFor="let action of actions"> 15 <ng-container *ngFor="let action of actions">
14 <ng-container *ngIf="action.isDisplayed === undefined || action.isDisplayed(entry) === true"> 16 <ng-container *ngIf="action.isDisplayed === undefined || action.isDisplayed(entry) === true">
15 <a *ngIf="action.linkBuilder" class="dropdown-item" [routerLink]="action.linkBuilder(entry)">{{ action.label }}</a>
16 17
17 <span *ngIf="!action.linkBuilder" class="custom-action dropdown-item" (click)="action.handler(entry)" role="button"> 18 <a *ngIf="action.linkBuilder" [ngClass]="{ 'with-icon': !!action.iconName }" class="dropdown-item" [routerLink]="action.linkBuilder(entry)">
19 <my-global-icon *ngIf="action.iconName" [iconName]="action.iconName" [ngClass]="'icon-' + action.iconName"></my-global-icon>
20 {{ action.label }}
21 </a>
22
23 <span
24 *ngIf="!action.linkBuilder" [ngClass]="{ 'with-icon': !!action.iconName }" (click)="action.handler(entry)"
25 class="custom-action dropdown-item" role="button"
26 >
27 <my-global-icon *ngIf="action.iconName" [iconName]="action.iconName" [ngClass]="'icon-' + action.iconName"></my-global-icon>
18 {{ action.label }} 28 {{ action.label }}
19 </span> 29 </span>
30
20 </ng-container> 31 </ng-container>
21 </ng-container> 32 </ng-container>
22 33
23 <div class="dropdown-divider"></div> 34 <div *ngIf="areActionsDisplayed(actions, entry)" class="dropdown-divider"></div>
24 35
25 </ng-container> 36 </ng-container>
26 </div> 37 </div>
diff --git a/client/src/app/shared/buttons/action-dropdown.component.scss b/client/src/app/shared/buttons/action-dropdown.component.scss
index 985b2ca88..5073190b0 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.scss
+++ b/client/src/app/shared/buttons/action-dropdown.component.scss
@@ -8,12 +8,19 @@
8.action-button { 8.action-button {
9 @include peertube-button; 9 @include peertube-button;
10 10
11 &.grey { 11 &.button-styled {
12 @include grey-button; 12
13 } 13 &.grey {
14 @include grey-button;
15 }
16
17 &.orange {
18 @include orange-button;
19 }
14 20
15 &.orange { 21 &:hover, &:active, &:focus {
16 @include orange-button; 22 background-color: $grey-background-color;
23 }
17 } 24 }
18 25
19 display: inline-block; 26 display: inline-block;
@@ -23,10 +30,6 @@
23 display: none; 30 display: none;
24 } 31 }
25 32
26 &:hover, &:active, &:focus {
27 background-color: $grey-background-color;
28 }
29
30 .more-icon { 33 .more-icon {
31 width: 21px; 34 width: 21px;
32 } 35 }
@@ -48,6 +51,10 @@
48 cursor: pointer; 51 cursor: pointer;
49 color: #000 !important; 52 color: #000 !important;
50 53
54 &.with-icon {
55 @include dropdown-with-icon-item;
56 }
57
51 a, span { 58 a, span {
52 display: block; 59 display: block;
53 width: 100%; 60 width: 100%;
diff --git a/client/src/app/shared/buttons/action-dropdown.component.ts b/client/src/app/shared/buttons/action-dropdown.component.ts
index 275e2b51e..f5345831b 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.ts
+++ b/client/src/app/shared/buttons/action-dropdown.component.ts
@@ -1,12 +1,18 @@
1import { Component, Input } from '@angular/core' 1import { Component, Input } from '@angular/core'
2import { GlobalIconName } from '@app/shared/images/global-icon.component'
2 3
3export type DropdownAction<T> = { 4export type DropdownAction<T> = {
4 label?: string 5 label?: string
6 iconName?: GlobalIconName
5 handler?: (a: T) => any 7 handler?: (a: T) => any
6 linkBuilder?: (a: T) => (string | number)[] 8 linkBuilder?: (a: T) => (string | number)[]
7 isDisplayed?: (a: T) => boolean 9 isDisplayed?: (a: T) => boolean
8} 10}
9 11
12export type DropdownButtonSize = 'normal' | 'small'
13export type DropdownTheme = 'orange' | 'grey'
14export type DropdownDirection = 'horizontal' | 'vertical'
15
10@Component({ 16@Component({
11 selector: 'my-action-dropdown', 17 selector: 'my-action-dropdown',
12 styleUrls: [ './action-dropdown.component.scss' ], 18 styleUrls: [ './action-dropdown.component.scss' ],
@@ -16,14 +22,29 @@ export type DropdownAction<T> = {
16export class ActionDropdownComponent<T> { 22export class ActionDropdownComponent<T> {
17 @Input() actions: DropdownAction<T>[] | DropdownAction<T>[][] = [] 23 @Input() actions: DropdownAction<T>[] | DropdownAction<T>[][] = []
18 @Input() entry: T 24 @Input() entry: T
25
19 @Input() placement = 'bottom-left' 26 @Input() placement = 'bottom-left'
20 @Input() buttonSize: 'normal' | 'small' = 'normal' 27
28 @Input() buttonSize: DropdownButtonSize = 'normal'
29 @Input() buttonDirection: DropdownDirection = 'horizontal'
30 @Input() buttonStyled = true
31
21 @Input() label: string 32 @Input() label: string
22 @Input() theme: 'orange' | 'grey' = 'grey' 33 @Input() theme: DropdownTheme = 'grey'
23 34
24 getActions () { 35 getActions () {
25 if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions 36 if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions
26 37
27 return [ this.actions ] 38 return [ this.actions ]
28 } 39 }
40
41 areActionsDisplayed (actions: DropdownAction<T>[], entry: T) {
42 return actions.some(a => a.isDisplayed === undefined || a.isDisplayed(entry))
43 }
44
45 handleClick (event: Event, action: DropdownAction<T>) {
46 event.preventDefault()
47
48 // action.handler(entry)
49 }
29} 50}
diff --git a/client/src/app/shared/buttons/button.component.ts b/client/src/app/shared/buttons/button.component.ts
index a91e9c7eb..c2b69d31a 100644
--- a/client/src/app/shared/buttons/button.component.ts
+++ b/client/src/app/shared/buttons/button.component.ts
@@ -1,5 +1,5 @@
1import { Component, Input } from '@angular/core' 1import { Component, Input } from '@angular/core'
2import { GlobalIconName } from '@app/shared/icons/global-icon.component' 2import { GlobalIconName } from '@app/shared/images/global-icon.component'
3 3
4@Component({ 4@Component({
5 selector: 'my-button', 5 selector: 'my-button',
diff --git a/client/src/app/shared/forms/form-reactive.ts b/client/src/app/shared/forms/form-reactive.ts
index b9873af2c..0d40b6f4a 100644
--- a/client/src/app/shared/forms/form-reactive.ts
+++ b/client/src/app/shared/forms/form-reactive.ts
@@ -59,7 +59,7 @@ export abstract class FormReactive {
59 const isDirty = control.dirty || forceCheck === true 59 const isDirty = control.dirty || forceCheck === true
60 if (control && isDirty && !control.valid) { 60 if (control && isDirty && !control.valid) {
61 const messages = validationMessages[ field ] 61 const messages = validationMessages[ field ]
62 for (const key in control.errors) { 62 for (const key of Object.keys(control.errors)) {
63 formErrors[ field ] += messages[ key ] + ' ' 63 formErrors[ field ] += messages[ key ] + ' '
64 } 64 }
65 } 65 }
diff --git a/client/src/app/shared/forms/form-validators/index.ts b/client/src/app/shared/forms/form-validators/index.ts
index fdcbedb71..e3de3ae13 100644
--- a/client/src/app/shared/forms/form-validators/index.ts
+++ b/client/src/app/shared/forms/form-validators/index.ts
@@ -10,6 +10,7 @@ export * from './video-blacklist-validators.service'
10export * from './video-channel-validators.service' 10export * from './video-channel-validators.service'
11export * from './video-comment-validators.service' 11export * from './video-comment-validators.service'
12export * from './video-validators.service' 12export * from './video-validators.service'
13export * from './video-playlist-validators.service'
13export * from './video-captions-validators.service' 14export * from './video-captions-validators.service'
14export * from './video-change-ownership-validators.service' 15export * from './video-change-ownership-validators.service'
15export * from './video-accept-ownership-validators.service' 16export * from './video-accept-ownership-validators.service'
diff --git a/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts b/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts
new file mode 100644
index 000000000..a2c9a5368
--- /dev/null
+++ b/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts
@@ -0,0 +1,66 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { AbstractControl, FormControl, Validators } from '@angular/forms'
3import { Injectable } from '@angular/core'
4import { BuildFormValidator } from '@app/shared'
5import { VideoPlaylistPrivacy } from '@shared/models'
6
7@Injectable()
8export class VideoPlaylistValidatorsService {
9 readonly VIDEO_PLAYLIST_DISPLAY_NAME: BuildFormValidator
10 readonly VIDEO_PLAYLIST_PRIVACY: BuildFormValidator
11 readonly VIDEO_PLAYLIST_DESCRIPTION: BuildFormValidator
12 readonly VIDEO_PLAYLIST_CHANNEL_ID: BuildFormValidator
13
14 constructor (private i18n: I18n) {
15 this.VIDEO_PLAYLIST_DISPLAY_NAME = {
16 VALIDATORS: [
17 Validators.required,
18 Validators.minLength(1),
19 Validators.maxLength(120)
20 ],
21 MESSAGES: {
22 'required': this.i18n('Display name is required.'),
23 'minlength': this.i18n('Display name must be at least 1 character long.'),
24 'maxlength': this.i18n('Display name cannot be more than 120 characters long.')
25 }
26 }
27
28 this.VIDEO_PLAYLIST_PRIVACY = {
29 VALIDATORS: [
30 Validators.required
31 ],
32 MESSAGES: {
33 'required': this.i18n('Privacy is required.')
34 }
35 }
36
37 this.VIDEO_PLAYLIST_DESCRIPTION = {
38 VALIDATORS: [
39 Validators.minLength(3),
40 Validators.maxLength(1000)
41 ],
42 MESSAGES: {
43 'minlength': i18n('Description must be at least 3 characters long.'),
44 'maxlength': i18n('Description cannot be more than 1000 characters long.')
45 }
46 }
47
48 this.VIDEO_PLAYLIST_CHANNEL_ID = {
49 VALIDATORS: [ ],
50 MESSAGES: {
51 'required': this.i18n('The channel is required when the playlist is public.')
52 }
53 }
54 }
55
56 setChannelValidator (channelControl: AbstractControl, privacy: VideoPlaylistPrivacy) {
57 if (privacy.toString() === VideoPlaylistPrivacy.PUBLIC.toString()) {
58 channelControl.setValidators([ Validators.required ])
59 } else {
60 channelControl.setValidators(null)
61 }
62
63 channelControl.markAsDirty()
64 channelControl.updateValueAndValidity()
65 }
66}
diff --git a/client/src/app/shared/forms/form-validators/video-validators.service.ts b/client/src/app/shared/forms/form-validators/video-validators.service.ts
index 81ed0666f..e3f7a0969 100644
--- a/client/src/app/shared/forms/form-validators/video-validators.service.ts
+++ b/client/src/app/shared/forms/form-validators/video-validators.service.ts
@@ -16,6 +16,7 @@ export class VideoValidatorsService {
16 readonly VIDEO_TAGS: BuildFormValidator 16 readonly VIDEO_TAGS: BuildFormValidator
17 readonly VIDEO_SUPPORT: BuildFormValidator 17 readonly VIDEO_SUPPORT: BuildFormValidator
18 readonly VIDEO_SCHEDULE_PUBLICATION_AT: BuildFormValidator 18 readonly VIDEO_SCHEDULE_PUBLICATION_AT: BuildFormValidator
19 readonly VIDEO_ORIGINALLY_PUBLISHED_AT: BuildFormValidator
19 20
20 constructor (private i18n: I18n) { 21 constructor (private i18n: I18n) {
21 22
@@ -92,5 +93,10 @@ export class VideoValidatorsService {
92 'required': this.i18n('A date is required to schedule video update.') 93 'required': this.i18n('A date is required to schedule video update.')
93 } 94 }
94 } 95 }
96
97 this.VIDEO_ORIGINALLY_PUBLISHED_AT = {
98 VALIDATORS: [ ],
99 MESSAGES: {}
100 }
95 } 101 }
96} 102}
diff --git a/client/src/app/shared/forms/markdown-textarea.component.ts b/client/src/app/shared/forms/markdown-textarea.component.ts
index e87aca0d4..49a57f29d 100644
--- a/client/src/app/shared/forms/markdown-textarea.component.ts
+++ b/client/src/app/shared/forms/markdown-textarea.component.ts
@@ -82,11 +82,11 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
82 return this.screenService.isInSmallView() === false 82 return this.screenService.isInSmallView() === false
83 } 83 }
84 84
85 private updatePreviews () { 85 private async updatePreviews () {
86 if (this.content === null || this.content === undefined) return 86 if (this.content === null || this.content === undefined) return
87 87
88 this.truncatedPreviewHTML = this.markdownRender(truncate(this.content, { length: this.truncate })) 88 this.truncatedPreviewHTML = await this.markdownRender(truncate(this.content, { length: this.truncate }))
89 this.previewHTML = this.markdownRender(this.content) 89 this.previewHTML = await this.markdownRender(this.content)
90 } 90 }
91 91
92 private markdownRender (text: string) { 92 private markdownRender (text: string) {
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.scss b/client/src/app/shared/forms/peertube-checkbox.component.scss
index 6e4e20775..ea321ee65 100644
--- a/client/src/app/shared/forms/peertube-checkbox.component.scss
+++ b/client/src/app/shared/forms/peertube-checkbox.component.scss
@@ -28,4 +28,4 @@
28 position: relative; 28 position: relative;
29 top: -2px; 29 top: -2px;
30 } 30 }
31} \ No newline at end of file 31}
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.ts b/client/src/app/shared/forms/peertube-checkbox.component.ts
index c1a6915e8..9578f5618 100644
--- a/client/src/app/shared/forms/peertube-checkbox.component.ts
+++ b/client/src/app/shared/forms/peertube-checkbox.component.ts
@@ -1,4 +1,4 @@
1import { Component, forwardRef, Input } from '@angular/core' 1import { ChangeDetectorRef, Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3 3
4@Component({ 4@Component({
@@ -21,10 +21,19 @@ export class PeertubeCheckboxComponent implements ControlValueAccessor {
21 @Input() helpHtml: string 21 @Input() helpHtml: string
22 @Input() disabled = false 22 @Input() disabled = false
23 23
24 // FIXME: https://github.com/angular/angular/issues/10816#issuecomment-307567836
25 @Input() onPushWorkaround = false
26
27 constructor (private cdr: ChangeDetectorRef) { }
28
24 propagateChange = (_: any) => { /* empty */ } 29 propagateChange = (_: any) => { /* empty */ }
25 30
26 writeValue (checked: boolean) { 31 writeValue (checked: boolean) {
27 this.checked = checked 32 this.checked = checked
33
34 if (this.onPushWorkaround) {
35 this.cdr.markForCheck()
36 }
28 } 37 }
29 38
30 registerOnChange (fn: (_: any) => void) { 39 registerOnChange (fn: (_: any) => void) {
diff --git a/client/src/app/shared/forms/timestamp-input.component.html b/client/src/app/shared/forms/timestamp-input.component.html
new file mode 100644
index 000000000..c57a4b32c
--- /dev/null
+++ b/client/src/app/shared/forms/timestamp-input.component.html
@@ -0,0 +1,4 @@
1<p-inputMask
2 [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
3 mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()"
4></p-inputMask>
diff --git a/client/src/app/shared/forms/timestamp-input.component.scss b/client/src/app/shared/forms/timestamp-input.component.scss
new file mode 100644
index 000000000..7115777fd
--- /dev/null
+++ b/client/src/app/shared/forms/timestamp-input.component.scss
@@ -0,0 +1,8 @@
1p-inputmask {
2 /deep/ input {
3 width: 80px;
4 font-size: 15px;
5
6 border: none;
7 }
8}
diff --git a/client/src/app/shared/forms/timestamp-input.component.ts b/client/src/app/shared/forms/timestamp-input.component.ts
new file mode 100644
index 000000000..8d67a96ac
--- /dev/null
+++ b/client/src/app/shared/forms/timestamp-input.component.ts
@@ -0,0 +1,61 @@
1import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { secondsToTime, timeToInt } from '../../../assets/player/utils'
4
5@Component({
6 selector: 'my-timestamp-input',
7 styleUrls: [ './timestamp-input.component.scss' ],
8 templateUrl: './timestamp-input.component.html',
9 providers: [
10 {
11 provide: NG_VALUE_ACCESSOR,
12 useExisting: forwardRef(() => TimestampInputComponent),
13 multi: true
14 }
15 ]
16})
17export class TimestampInputComponent implements ControlValueAccessor, OnInit {
18 @Input() maxTimestamp: number
19 @Input() timestamp: number
20 @Input() disabled = false
21
22 timestampString: string
23
24 constructor (private changeDetector: ChangeDetectorRef) {}
25
26 ngOnInit () {
27 this.writeValue(this.timestamp || 0)
28 }
29
30 propagateChange = (_: any) => { /* empty */ }
31
32 writeValue (timestamp: number) {
33 this.timestamp = timestamp
34
35 this.timestampString = secondsToTime(this.timestamp, true, ':')
36 }
37
38 registerOnChange (fn: (_: any) => void) {
39 this.propagateChange = fn
40 }
41
42 registerOnTouched () {
43 // Unused
44 }
45
46 onModelChange () {
47 this.timestamp = timeToInt(this.timestampString)
48
49 this.propagateChange(this.timestamp)
50 }
51
52 onBlur () {
53 if (this.maxTimestamp && this.timestamp > this.maxTimestamp) {
54 this.writeValue(this.maxTimestamp)
55
56 this.changeDetector.detectChanges()
57
58 this.propagateChange(this.timestamp)
59 }
60 }
61}
diff --git a/client/src/app/shared/icons/global-icon.component.html b/client/src/app/shared/icons/global-icon.component.html
deleted file mode 100644
index e69de29bb..000000000
--- a/client/src/app/shared/icons/global-icon.component.html
+++ /dev/null
diff --git a/client/src/app/shared/icons/global-icon.component.scss b/client/src/app/shared/images/global-icon.component.scss
index 6805fb6f7..6805fb6f7 100644
--- a/client/src/app/shared/icons/global-icon.component.scss
+++ b/client/src/app/shared/images/global-icon.component.scss
diff --git a/client/src/app/shared/icons/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts
index e8ada0324..5a3db4531 100644
--- a/client/src/app/shared/icons/global-icon.component.ts
+++ b/client/src/app/shared/images/global-icon.component.ts
@@ -1,7 +1,9 @@
1import { Component, ElementRef, Input, OnInit } from '@angular/core' 1import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core'
2 2
3const icons = { 3const icons = {
4 'add': require('../../../assets/images/global/add.html'), 4 'add': require('../../../assets/images/global/add.html'),
5 'user': require('../../../assets/images/global/user.html'),
6 'sign-out': require('../../../assets/images/global/sign-out.html'),
5 'syndication': require('../../../assets/images/global/syndication.html'), 7 'syndication': require('../../../assets/images/global/syndication.html'),
6 'help': require('../../../assets/images/global/help.html'), 8 'help': require('../../../assets/images/global/help.html'),
7 'sparkle': require('../../../assets/images/global/sparkle.html'), 9 'sparkle': require('../../../assets/images/global/sparkle.html'),
@@ -11,21 +13,39 @@ const icons = {
11 'no': require('../../../assets/images/global/no.html'), 13 'no': require('../../../assets/images/global/no.html'),
12 'cloud-download': require('../../../assets/images/global/cloud-download.html'), 14 'cloud-download': require('../../../assets/images/global/cloud-download.html'),
13 'undo': require('../../../assets/images/global/undo.html'), 15 'undo': require('../../../assets/images/global/undo.html'),
16 'history': require('../../../assets/images/global/history.html'),
14 'circle-tick': require('../../../assets/images/global/circle-tick.html'), 17 'circle-tick': require('../../../assets/images/global/circle-tick.html'),
15 'cog': require('../../../assets/images/global/cog.html'), 18 'cog': require('../../../assets/images/global/cog.html'),
16 'download': require('../../../assets/images/global/download.html'), 19 'download': require('../../../assets/images/global/download.html'),
20 'go': require('../../../assets/images/menu/go.html'),
17 'edit': require('../../../assets/images/global/edit.html'), 21 'edit': require('../../../assets/images/global/edit.html'),
18 'im-with-her': require('../../../assets/images/global/im-with-her.html'), 22 'im-with-her': require('../../../assets/images/global/im-with-her.html'),
19 'delete': require('../../../assets/images/global/delete.html'), 23 'delete': require('../../../assets/images/global/delete.html'),
24 'server': require('../../../assets/images/global/server.html'),
20 'cross': require('../../../assets/images/global/cross.html'), 25 'cross': require('../../../assets/images/global/cross.html'),
21 'validate': require('../../../assets/images/global/validate.html'), 26 'validate': require('../../../assets/images/global/validate.html'),
22 'tick': require('../../../assets/images/global/tick.html'), 27 'tick': require('../../../assets/images/global/tick.html'),
23 'dislike': require('../../../assets/images/video/dislike.html'), 28 'dislike': require('../../../assets/images/video/dislike.html'),
24 'heart': require('../../../assets/images/video/heart.html'), 29 'heart': require('../../../assets/images/video/heart.html'),
25 'like': require('../../../assets/images/video/like.html'), 30 'like': require('../../../assets/images/video/like.html'),
26 'more': require('../../../assets/images/video/more.html'), 31 'more-horizontal': require('../../../assets/images/global/more-horizontal.html'),
32 'more-vertical': require('../../../assets/images/global/more-vertical.html'),
27 'share': require('../../../assets/images/video/share.html'), 33 'share': require('../../../assets/images/video/share.html'),
28 'upload': require('../../../assets/images/video/upload.html') 34 'upload': require('../../../assets/images/video/upload.html'),
35 'playlist-add': require('../../../assets/images/video/playlist-add.html'),
36 'play': require('../../../assets/images/global/play.html'),
37 'playlists': require('../../../assets/images/global/playlists.html'),
38 'about': require('../../../assets/images/menu/about.html'),
39 'globe': require('../../../assets/images/menu/globe.html'),
40 'home': require('../../../assets/images/menu/home.html'),
41 'recently-added': require('../../../assets/images/menu/recently-added.html'),
42 'trending': require('../../../assets/images/menu/trending.html'),
43 'videos': require('../../../assets/images/global/videos.html'),
44 'folder': require('../../../assets/images/global/folder.html'),
45 'administration': require('../../../assets/images/menu/administration.html'),
46 'subscriptions': require('../../../assets/images/menu/subscriptions.html'),
47 'users': require('../../../assets/images/global/users.html'),
48 'refresh': require('../../../assets/images/global/refresh.html')
29} 49}
30 50
31export type GlobalIconName = keyof typeof icons 51export type GlobalIconName = keyof typeof icons
@@ -33,7 +53,8 @@ export type GlobalIconName = keyof typeof icons
33@Component({ 53@Component({
34 selector: 'my-global-icon', 54 selector: 'my-global-icon',
35 template: '', 55 template: '',
36 styleUrls: [ './global-icon.component.scss' ] 56 styleUrls: [ './global-icon.component.scss' ],
57 changeDetection: ChangeDetectionStrategy.OnPush
37}) 58})
38export class GlobalIconComponent implements OnInit { 59export class GlobalIconComponent implements OnInit {
39 @Input() iconName: GlobalIconName 60 @Input() iconName: GlobalIconName
diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.html b/client/src/app/shared/images/image-upload.component.html
index c09c862c4..c09c862c4 100644
--- a/client/src/app/videos/+video-edit/shared/video-image.component.html
+++ b/client/src/app/shared/images/image-upload.component.html
diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.scss b/client/src/app/shared/images/image-upload.component.scss
index b63963bca..b63963bca 100644
--- a/client/src/app/videos/+video-edit/shared/video-image.component.scss
+++ b/client/src/app/shared/images/image-upload.component.scss
diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.ts b/client/src/app/shared/images/image-upload.component.ts
index a604cde90..2da1592ff 100644
--- a/client/src/app/videos/+video-edit/shared/video-image.component.ts
+++ b/client/src/app/shared/images/image-upload.component.ts
@@ -4,18 +4,18 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
4import { ServerService } from '@app/core' 4import { ServerService } from '@app/core'
5 5
6@Component({ 6@Component({
7 selector: 'my-video-image', 7 selector: 'my-image-upload',
8 styleUrls: [ './video-image.component.scss' ], 8 styleUrls: [ './image-upload.component.scss' ],
9 templateUrl: './video-image.component.html', 9 templateUrl: './image-upload.component.html',
10 providers: [ 10 providers: [
11 { 11 {
12 provide: NG_VALUE_ACCESSOR, 12 provide: NG_VALUE_ACCESSOR,
13 useExisting: forwardRef(() => VideoImageComponent), 13 useExisting: forwardRef(() => ImageUploadComponent),
14 multi: true 14 multi: true
15 } 15 }
16 ] 16 ]
17}) 17})
18export class VideoImageComponent implements ControlValueAccessor { 18export class ImageUploadComponent implements ControlValueAccessor {
19 @Input() inputLabel: string 19 @Input() inputLabel: string
20 @Input() inputName: string 20 @Input() inputName: string
21 @Input() previewWidth: string 21 @Input() previewWidth: string
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 dc8db8cc1..2885f97e3 100644
--- a/client/src/app/shared/instance/instance-features-table.component.html
+++ b/client/src/app/shared/instance/instance-features-table.component.html
@@ -2,6 +2,20 @@
2 2
3 <table class="table"> 3 <table class="table">
4 <tr> 4 <tr>
5 <td i18n class="label">Default NSFW/sensitive videos policy (can be redefined by the users)</td>
6
7 <td class="value">{{ buildNSFWLabel() }}</td>
8 </tr>
9
10 <tr *ngFor="let feature of features">
11 <td class="label">{{ feature.label }}</td>
12 <td>
13 <span *ngIf="feature.value === true" class="glyphicon glyphicon-ok"></span>
14 <span *ngIf="feature.value === false" class="glyphicon glyphicon-remove"></span>
15 </td>
16 </tr>
17
18 <tr>
5 <td i18n class="label">Video quota</td> 19 <td i18n class="label">Video quota</td>
6 20
7 <td class="value"> 21 <td class="value">
@@ -16,13 +30,5 @@
16 </ng-container> 30 </ng-container>
17 </td> 31 </td>
18 </tr> 32 </tr>
19
20 <tr *ngFor="let feature of features">
21 <td class="label">{{ feature.label }}</td>
22 <td>
23 <span *ngIf="feature.value === true" class="glyphicon glyphicon-ok"></span>
24 <span *ngIf="feature.value === false" class="glyphicon glyphicon-remove"></span>
25 </td>
26 </tr>
27 </table> 33 </table>
28</div> \ No newline at end of file 34</div>
diff --git a/client/src/app/shared/instance/instance-features-table.component.ts b/client/src/app/shared/instance/instance-features-table.component.ts
index da8da0702..72e7c2730 100644
--- a/client/src/app/shared/instance/instance-features-table.component.ts
+++ b/client/src/app/shared/instance/instance-features-table.component.ts
@@ -33,11 +33,27 @@ export class InstanceFeaturesTableComponent implements OnInit {
33 }) 33 })
34 } 34 }
35 35
36 buildNSFWLabel () {
37 const policy = this.serverService.getConfig().instance.defaultNSFWPolicy
38
39 if (policy === 'do_not_list') return this.i18n('Hidden')
40 if (policy === 'blur') return this.i18n('Blurred with confirmation request')
41 if (policy === 'display') return this.i18n('Displayed')
42 }
43
36 private buildFeatures () { 44 private buildFeatures () {
37 const config = this.serverService.getConfig() 45 const config = this.serverService.getConfig()
38 46
39 this.features = [ 47 this.features = [
40 { 48 {
49 label: this.i18n('User registration allowed'),
50 value: config.signup.allowed
51 },
52 {
53 label: this.i18n('Video uploads require manual validation by moderators'),
54 value: config.autoBlacklist.videos.ofUsers.enabled
55 },
56 {
41 label: this.i18n('Transcode your videos in multiple resolutions'), 57 label: this.i18n('Transcode your videos in multiple resolutions'),
42 value: config.transcoding.enabledResolutions.length !== 0 58 value: config.transcoding.enabledResolutions.length !== 0
43 }, 59 },
@@ -48,9 +64,12 @@ export class InstanceFeaturesTableComponent implements OnInit {
48 { 64 {
49 label: this.i18n('Torrent import'), 65 label: this.i18n('Torrent import'),
50 value: config.import.videos.torrent.enabled 66 value: config.import.videos.torrent.enabled
67 },
68 {
69 label: this.i18n('P2P enabled'),
70 value: config.tracker.enabled
51 } 71 }
52 ] 72 ]
53
54 } 73 }
55 74
56 private getApproximateTime (seconds: number) { 75 private getApproximateTime (seconds: number) {
@@ -84,5 +103,4 @@ export class InstanceFeaturesTableComponent implements OnInit {
84 103
85 this.quotaHelpIndication = lines.join('<br />') 104 this.quotaHelpIndication = lines.join('<br />')
86 } 105 }
87
88} 106}
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 d3c896019..35511ee62 100644
--- a/client/src/app/shared/menu/top-menu-dropdown.component.html
+++ b/client/src/app/shared/menu/top-menu-dropdown.component.html
@@ -3,7 +3,7 @@
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">{{ menuEntry.label }}</a>
5 5
6 <div *ngIf="!menuEntry.routerLink" ngbDropdown class="parent-entry" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)"> 6 <div *ngIf="!menuEntry.routerLink" ngbDropdown [container]="container" class="parent-entry" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)">
7 <span 7 <span
8 (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor 8 (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor
9 (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page" 9 (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page"
@@ -13,7 +13,11 @@
13 </span> 13 </span>
14 14
15 <div ngbDropdownMenu> 15 <div ngbDropdownMenu>
16 <a *ngFor="let menuChild of menuEntry.children" class="dropdown-item" [routerLink]="menuChild.routerLink">{{ menuChild.label }}</a> 16 <a *ngFor="let menuChild of menuEntry.children" class="dropdown-item" [ngClass]="{ icon: hasIcons }" [routerLink]="menuChild.routerLink">
17 <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName"></my-global-icon>
18
19 {{ menuChild.label }}
20 </a>
17 </div> 21 </div>
18 </div> 22 </div>
19 23
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 77159532f..d7c7de957 100644
--- a/client/src/app/shared/menu/top-menu-dropdown.component.scss
+++ b/client/src/app/shared/menu/top-menu-dropdown.component.scss
@@ -1,3 +1,6 @@
1@import '_variables';
2@import '_mixins';
3
1.parent-entry { 4.parent-entry {
2 span[role=button] { 5 span[role=button] {
3 cursor: pointer; 6 cursor: pointer;
@@ -16,3 +19,9 @@
16/deep/ .dropdown-menu { 19/deep/ .dropdown-menu {
17 margin-top: 0 !important; 20 margin-top: 0 !important;
18} 21}
22
23.icon {
24 @include dropdown-with-icon-item;
25
26 top: -1px;
27}
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 e859c30dd..5ccdafb54 100644
--- a/client/src/app/shared/menu/top-menu-dropdown.component.ts
+++ b/client/src/app/shared/menu/top-menu-dropdown.component.ts
@@ -3,6 +3,8 @@ import { filter, take } from 'rxjs/operators'
3import { NavigationEnd, Router } from '@angular/router' 3import { NavigationEnd, Router } from '@angular/router'
4import { Subscription } from 'rxjs' 4import { Subscription } from 'rxjs'
5import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' 5import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
6import { GlobalIconName } from '@app/shared/images/global-icon.component'
7import { ScreenService } from '@app/shared/misc/screen.service'
6 8
7export type TopMenuDropdownParam = { 9export type TopMenuDropdownParam = {
8 label: string 10 label: string
@@ -11,6 +13,8 @@ export type TopMenuDropdownParam = {
11 children?: { 13 children?: {
12 label: string 14 label: string
13 routerLink: string 15 routerLink: string
16
17 iconName?: GlobalIconName
14 }[] 18 }[]
15} 19}
16 20
@@ -23,11 +27,16 @@ export class TopMenuDropdownComponent implements OnInit, OnDestroy {
23 @Input() menuEntries: TopMenuDropdownParam[] = [] 27 @Input() menuEntries: TopMenuDropdownParam[] = []
24 28
25 suffixLabels: { [ parentLabel: string ]: string } 29 suffixLabels: { [ parentLabel: string ]: string }
30 hasIcons = false
31 container: undefined | 'body' = undefined
26 32
27 private openedOnHover = false 33 private openedOnHover = false
28 private routeSub: Subscription 34 private routeSub: Subscription
29 35
30 constructor (private router: Router) {} 36 constructor (
37 private router: Router,
38 private screen: ScreenService
39 ) {}
31 40
32 ngOnInit () { 41 ngOnInit () {
33 this.updateChildLabels(window.location.pathname) 42 this.updateChildLabels(window.location.pathname)
@@ -35,6 +44,16 @@ export class TopMenuDropdownComponent implements OnInit, OnDestroy {
35 this.routeSub = this.router.events 44 this.routeSub = this.router.events
36 .pipe(filter(event => event instanceof NavigationEnd)) 45 .pipe(filter(event => event instanceof NavigationEnd))
37 .subscribe(() => this.updateChildLabels(window.location.pathname)) 46 .subscribe(() => this.updateChildLabels(window.location.pathname))
47
48 this.hasIcons = this.menuEntries.some(
49 e => e.children && e.children.some(c => !!c.iconName)
50 )
51
52 // FIXME: We have to set body for the container to avoid because of scroll overflow on mobile view
53 // But this break our hovering system
54 if (this.screen.isInMobileView()) {
55 this.container = 'body'
56 }
38 } 57 }
39 58
40 ngOnDestroy () { 59 ngOnDestroy () {
@@ -48,7 +67,7 @@ export class TopMenuDropdownComponent implements OnInit, OnDestroy {
48 // Menu was closed 67 // Menu was closed
49 dropdown.openChange 68 dropdown.openChange
50 .pipe(take(1)) 69 .pipe(take(1))
51 .subscribe(e => this.openedOnHover = false) 70 .subscribe(() => this.openedOnHover = false)
52 } 71 }
53 72
54 dropdownAnchorClicked (dropdown: NgbDropdown) { 73 dropdownAnchorClicked (dropdown: NgbDropdown) {
diff --git a/client/src/app/shared/misc/help.component.html b/client/src/app/shared/misc/help.component.html
index 444425c9f..e31eef06a 100644
--- a/client/src/app/shared/misc/help.component.html
+++ b/client/src/app/shared/misc/help.component.html
@@ -22,7 +22,7 @@
22 [attr.aria-pressed]="isPopoverOpened" 22 [attr.aria-pressed]="isPopoverOpened"
23 [ngbPopover]="tooltipTemplate" 23 [ngbPopover]="tooltipTemplate"
24 [placement]="tooltipPlacement" 24 [placement]="tooltipPlacement"
25 [autoClose]="true" 25 autoClose="outside"
26 (onHidden)="onPopoverHidden()" 26 (onHidden)="onPopoverHidden()"
27 (onShown)="onPopoverShown()" 27 (onShown)="onPopoverShown()"
28> 28>
diff --git a/client/src/app/shared/misc/loader.component.html b/client/src/app/shared/misc/loader.component.html
index 38d06950e..b8b7ad343 100644
--- a/client/src/app/shared/misc/loader.component.html
+++ b/client/src/app/shared/misc/loader.component.html
@@ -1,3 +1,8 @@
1<div id="video-loading" *ngIf="loading"> 1<div *ngIf="loading">
2 <div class="glyphicon glyphicon-refresh glyphicon-refresh-animate"></div> 2 <div class="lds-ring">
3 <div></div>
4 <div></div>
5 <div></div>
6 <div></div>
7 </div>
3</div> 8</div>
diff --git a/client/src/app/shared/misc/loader.component.scss b/client/src/app/shared/misc/loader.component.scss
new file mode 100644
index 000000000..ddb64f07a
--- /dev/null
+++ b/client/src/app/shared/misc/loader.component.scss
@@ -0,0 +1,45 @@
1@import '_variables';
2@import '_mixins';
3
4// Thanks to https://loading.io/css/ (CC0 License)
5
6.lds-ring {
7 display: inline-block;
8 position: relative;
9 width: 50px;
10 height: 50px;
11}
12
13.lds-ring div {
14 box-sizing: border-box;
15 display: block;
16 position: absolute;
17 width: 44px;
18 height: 44px;
19 margin: 6px;
20 border: 4px solid;
21 border-radius: 50%;
22 animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
23 border-color: #999999 transparent transparent transparent;
24}
25
26.lds-ring div:nth-child(1) {
27 animation-delay: -0.45s;
28}
29
30.lds-ring div:nth-child(2) {
31 animation-delay: -0.3s;
32}
33
34.lds-ring div:nth-child(3) {
35 animation-delay: -0.15s;
36}
37
38@keyframes lds-ring {
39 0% {
40 transform: rotate(0deg);
41 }
42 100% {
43 transform: rotate(360deg);
44 }
45}
diff --git a/client/src/app/shared/misc/loader.component.ts b/client/src/app/shared/misc/loader.component.ts
index f37d70c85..e3b1eea3a 100644
--- a/client/src/app/shared/misc/loader.component.ts
+++ b/client/src/app/shared/misc/loader.component.ts
@@ -2,10 +2,9 @@ import { Component, Input } from '@angular/core'
2 2
3@Component({ 3@Component({
4 selector: 'my-loader', 4 selector: 'my-loader',
5 styleUrls: [ ], 5 styleUrls: [ './loader.component.scss' ],
6 templateUrl: './loader.component.html' 6 templateUrl: './loader.component.html'
7}) 7})
8
9export class LoaderComponent { 8export class LoaderComponent {
10 @Input() loading: boolean 9 @Input() loading: boolean
11} 10}
diff --git a/client/src/app/shared/misc/screen.service.ts b/client/src/app/shared/misc/screen.service.ts
index 1cbc96b14..af75569d9 100644
--- a/client/src/app/shared/misc/screen.service.ts
+++ b/client/src/app/shared/misc/screen.service.ts
@@ -18,6 +18,10 @@ export class ScreenService {
18 return this.getWindowInnerWidth() < 500 18 return this.getWindowInnerWidth() < 500
19 } 19 }
20 20
21 isInTouchScreen () {
22 return 'ontouchstart' in window || navigator.msMaxTouchPoints
23 }
24
21 // Cache window inner width, because it's an expensive call 25 // Cache window inner width, because it's an expensive call
22 private getWindowInnerWidth () { 26 private getWindowInnerWidth () {
23 if (this.cacheWindowInnerWidthExpired()) this.refreshWindowInnerWidth() 27 if (this.cacheWindowInnerWidthExpired()) this.refreshWindowInnerWidth()
@@ -32,6 +36,8 @@ export class ScreenService {
32 } 36 }
33 37
34 private cacheWindowInnerWidthExpired () { 38 private cacheWindowInnerWidthExpired () {
39 if (!this.lastFunctionCallTime) return true
40
35 return new Date().getTime() > (this.lastFunctionCallTime + this.cacheForMs) 41 return new Date().getTime() > (this.lastFunctionCallTime + this.cacheForMs)
36 } 42 }
37} 43}
diff --git a/client/src/app/shared/misc/small-loader.component.html b/client/src/app/shared/misc/small-loader.component.html
new file mode 100644
index 000000000..5a7cea738
--- /dev/null
+++ b/client/src/app/shared/misc/small-loader.component.html
@@ -0,0 +1,3 @@
1<div *ngIf="loading">
2 <div class="glyphicon glyphicon-refresh glyphicon-refresh-animate"></div>
3</div>
diff --git a/client/src/app/shared/misc/small-loader.component.ts b/client/src/app/shared/misc/small-loader.component.ts
new file mode 100644
index 000000000..191877f14
--- /dev/null
+++ b/client/src/app/shared/misc/small-loader.component.ts
@@ -0,0 +1,11 @@
1import { Component, Input } from '@angular/core'
2
3@Component({
4 selector: 'my-small-loader',
5 styleUrls: [ ],
6 templateUrl: './small-loader.component.html'
7})
8
9export class SmallLoaderComponent {
10 @Input() loading: boolean
11}
diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts
index 7cc6055c2..85fc1c3a0 100644
--- a/client/src/app/shared/misc/utils.ts
+++ b/client/src/app/shared/misc/utils.ts
@@ -17,7 +17,7 @@ function getParameterByName (name: string, url: string) {
17 return decodeURIComponent(results[2].replace(/\+/g, ' ')) 17 return decodeURIComponent(results[2].replace(/\+/g, ' '))
18} 18}
19 19
20function populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support: string }[]) { 20function populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support?: string }[]) {
21 return new Promise(res => { 21 return new Promise(res => {
22 authService.userInformationLoaded 22 authService.userInformationLoaded
23 .subscribe( 23 .subscribe(
@@ -78,10 +78,10 @@ function objectToUrlEncoded (obj: any) {
78 78
79// Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34 79// Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34
80function objectToFormData (obj: any, form?: FormData, namespace?: string) { 80function objectToFormData (obj: any, form?: FormData, namespace?: string) {
81 let fd = form || new FormData() 81 const fd = form || new FormData()
82 let formKey 82 let formKey
83 83
84 for (let key of Object.keys(obj)) { 84 for (const key of Object.keys(obj)) {
85 if (namespace) formKey = `${namespace}[${key}]` 85 if (namespace) formKey = `${namespace}[${key}]`
86 else formKey = key 86 else formKey = key
87 87
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.scss b/client/src/app/shared/moderation/user-moderation-dropdown.component.scss
deleted file mode 100644
index e69de29bb..000000000
--- a/client/src/app/shared/moderation/user-moderation-dropdown.component.scss
+++ /dev/null
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 9a2461ebf..9dd16812b 100644
--- a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
+++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
@@ -10,8 +10,7 @@ import { BlocklistService } from '@app/shared/blocklist'
10 10
11@Component({ 11@Component({
12 selector: 'my-user-moderation-dropdown', 12 selector: 'my-user-moderation-dropdown',
13 templateUrl: './user-moderation-dropdown.component.html', 13 templateUrl: './user-moderation-dropdown.component.html'
14 styleUrls: [ './user-moderation-dropdown.component.scss' ]
15}) 14})
16export class UserModerationDropdownComponent implements OnChanges { 15export class UserModerationDropdownComponent implements OnChanges {
17 @ViewChild('userBanModal') userBanModal: UserBanModalComponent 16 @ViewChild('userBanModal') userBanModal: UserBanModalComponent
diff --git a/client/src/app/shared/overview/videos-overview.model.ts b/client/src/app/shared/overview/videos-overview.model.ts
index c8eafc8e8..21abe1697 100644
--- a/client/src/app/shared/overview/videos-overview.model.ts
+++ b/client/src/app/shared/overview/videos-overview.model.ts
@@ -1,9 +1,9 @@
1import { VideoChannelAttribute, VideoConstant, VideosOverview as VideosOverviewServer } from '../../../../../shared/models' 1import { VideoChannelSummary, VideoConstant, VideosOverview as VideosOverviewServer } from '../../../../../shared/models'
2import { Video } from '@app/shared/video/video.model' 2import { Video } from '@app/shared/video/video.model'
3 3
4export class VideosOverview implements VideosOverviewServer { 4export class VideosOverview implements VideosOverviewServer {
5 channels: { 5 channels: {
6 channel: VideoChannelAttribute 6 channel: VideoChannelSummary
7 videos: Video[] 7 videos: Video[]
8 }[] 8 }[]
9 9
diff --git a/client/src/app/shared/renderer/html-renderer.service.ts b/client/src/app/shared/renderer/html-renderer.service.ts
index d49df9b6d..28ef51e72 100644
--- a/client/src/app/shared/renderer/html-renderer.service.ts
+++ b/client/src/app/shared/renderer/html-renderer.service.ts
@@ -1,6 +1,5 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { LinkifierService } from '@app/shared/renderer/linkifier.service' 2import { LinkifierService } from '@app/shared/renderer/linkifier.service'
3import * as sanitizeHtml from 'sanitize-html'
4 3
5@Injectable() 4@Injectable()
6export class HtmlRendererService { 5export class HtmlRendererService {
@@ -9,7 +8,10 @@ export class HtmlRendererService {
9 8
10 } 9 }
11 10
12 toSafeHtml (text: string) { 11 async toSafeHtml (text: string) {
12 // FIXME: import('..') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function
13 const sanitizeHtml: typeof import ('sanitize-html') = (await import('sanitize-html') as any).default
14
13 // Convert possible markdown to html 15 // Convert possible markdown to html
14 const html = this.linkifier.linkify(text) 16 const html = this.linkifier.linkify(text)
15 17
diff --git a/client/src/app/shared/renderer/linkifier.service.ts b/client/src/app/shared/renderer/linkifier.service.ts
index 2529c9eaf..95d5f17cc 100644
--- a/client/src/app/shared/renderer/linkifier.service.ts
+++ b/client/src/app/shared/renderer/linkifier.service.ts
@@ -1,8 +1,7 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' 2import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
3// FIXME: use @types/linkify when https://github.com/DefinitelyTyped/DefinitelyTyped/pull/29682/files is merged? 3import * as linkify from 'linkifyjs'
4const linkify = require('linkifyjs') 4import linkifyHtml from 'linkifyjs/html'
5const linkifyHtml = require('linkifyjs/html')
6 5
7@Injectable() 6@Injectable()
8export class LinkifierService { 7export class LinkifierService {
diff --git a/client/src/app/shared/renderer/markdown.service.ts b/client/src/app/shared/renderer/markdown.service.ts
index 07017eca5..9a9066351 100644
--- a/client/src/app/shared/renderer/markdown.service.ts
+++ b/client/src/app/shared/renderer/markdown.service.ts
@@ -1,6 +1,6 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2 2
3import * as MarkdownIt from 'markdown-it' 3import { MarkdownIt } from 'markdown-it'
4 4
5@Injectable() 5@Injectable()
6export class MarkdownService { 6export class MarkdownService {
@@ -14,32 +14,38 @@ export class MarkdownService {
14 ] 14 ]
15 static ENHANCED_RULES = MarkdownService.TEXT_RULES.concat([ 'image' ]) 15 static ENHANCED_RULES = MarkdownService.TEXT_RULES.concat([ 'image' ])
16 16
17 private textMarkdownIt: MarkdownIt.MarkdownIt 17 private textMarkdownIt: MarkdownIt
18 private enhancedMarkdownIt: MarkdownIt.MarkdownIt 18 private enhancedMarkdownIt: MarkdownIt
19 19
20 constructor () { 20 async textMarkdownToHTML (markdown: string) {
21 this.textMarkdownIt = this.createMarkdownIt(MarkdownService.TEXT_RULES)
22 this.enhancedMarkdownIt = this.createMarkdownIt(MarkdownService.ENHANCED_RULES)
23 }
24
25 textMarkdownToHTML (markdown: string) {
26 if (!markdown) return '' 21 if (!markdown) return ''
27 22
23 if (!this.textMarkdownIt) {
24 this.textMarkdownIt = await this.createMarkdownIt(MarkdownService.TEXT_RULES)
25 }
26
28 const html = this.textMarkdownIt.render(markdown) 27 const html = this.textMarkdownIt.render(markdown)
29 return this.avoidTruncatedTags(html) 28 return this.avoidTruncatedTags(html)
30 } 29 }
31 30
32 enhancedMarkdownToHTML (markdown: string) { 31 async enhancedMarkdownToHTML (markdown: string) {
33 if (!markdown) return '' 32 if (!markdown) return ''
34 33
34 if (!this.enhancedMarkdownIt) {
35 this.enhancedMarkdownIt = await this.createMarkdownIt(MarkdownService.ENHANCED_RULES)
36 }
37
35 const html = this.enhancedMarkdownIt.render(markdown) 38 const html = this.enhancedMarkdownIt.render(markdown)
36 return this.avoidTruncatedTags(html) 39 return this.avoidTruncatedTags(html)
37 } 40 }
38 41
39 private createMarkdownIt (rules: string[]) { 42 private async createMarkdownIt (rules: string[]) {
40 const markdownIt = new MarkdownIt('zero', { linkify: true, breaks: true }) 43 // FIXME: import('..') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function
44 const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default
45
46 const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true })
41 47
42 for (let rule of rules) { 48 for (const rule of rules) {
43 markdownIt.enable(rule) 49 markdownIt.enable(rule)
44 } 50 }
45 51
@@ -48,7 +54,7 @@ export class MarkdownService {
48 return markdownIt 54 return markdownIt
49 } 55 }
50 56
51 private setTargetToLinks (markdownIt: MarkdownIt.MarkdownIt) { 57 private setTargetToLinks (markdownIt: MarkdownIt) {
52 // Snippet from markdown-it documentation: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer 58 // Snippet from markdown-it documentation: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
53 const defaultRender = markdownIt.renderer.rules.link_open || function (tokens, idx, options, env, self) { 59 const defaultRender = markdownIt.renderer.rules.link_open || function (tokens, idx, options, env, self) {
54 return self.renderToken(tokens, idx, options) 60 return self.renderToken(tokens, idx, options)
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index 6f8625c7e..ded65653f 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -14,10 +14,7 @@ import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
14import { ButtonComponent } from './buttons/button.component' 14import { ButtonComponent } from './buttons/button.component'
15import { DeleteButtonComponent } from './buttons/delete-button.component' 15import { DeleteButtonComponent } from './buttons/delete-button.component'
16import { EditButtonComponent } from './buttons/edit-button.component' 16import { EditButtonComponent } from './buttons/edit-button.component'
17import { FromNowPipe } from './misc/from-now.pipe'
18import { LoaderComponent } from './misc/loader.component' 17import { LoaderComponent } from './misc/loader.component'
19import { NumberFormatterPipe } from './misc/number-formatter.pipe'
20import { ObjectLengthPipe } from './misc/object-length.pipe'
21import { RestExtractor, RestService } from './rest' 18import { RestExtractor, RestService } from './rest'
22import { UserService } from './users' 19import { UserService } from './users'
23import { VideoAbuseService } from './video-abuse' 20import { VideoAbuseService } from './video-abuse'
@@ -45,9 +42,11 @@ import {
45 VideoChangeOwnershipValidatorsService, 42 VideoChangeOwnershipValidatorsService,
46 VideoChannelValidatorsService, 43 VideoChannelValidatorsService,
47 VideoCommentValidatorsService, 44 VideoCommentValidatorsService,
45 VideoPlaylistValidatorsService,
48 VideoValidatorsService 46 VideoValidatorsService
49} from '@app/shared/forms' 47} from '@app/shared/forms'
50import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' 48import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
49import { InputMaskModule } from 'primeng/inputmask'
51import { ScreenService } from '@app/shared/misc/screen.service' 50import { ScreenService } from '@app/shared/misc/screen.service'
52import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' 51import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
53import { VideoCaptionService } from '@app/shared/video-caption' 52import { VideoCaptionService } from '@app/shared/video-caption'
@@ -68,7 +67,24 @@ import { UserNotificationsComponent } from '@app/shared/users/user-notifications
68import { InstanceService } from '@app/shared/instance/instance.service' 67import { InstanceService } from '@app/shared/instance/instance.service'
69import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer' 68import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer'
70import { ConfirmComponent } from '@app/shared/confirm/confirm.component' 69import { ConfirmComponent } from '@app/shared/confirm/confirm.component'
71import { GlobalIconComponent } from '@app/shared/icons/global-icon.component' 70import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
71import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
72import { ImageUploadComponent } from '@app/shared/images/image-upload.component'
73import { GlobalIconComponent } from '@app/shared/images/global-icon.component'
74import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component'
75import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
76import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component'
77import { VideoPlaylistElementMiniatureComponent } from '@app/shared/video-playlist/video-playlist-element-miniature.component'
78import { VideosSelectionComponent } from '@app/shared/video/videos-selection.component'
79import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
80import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe'
81import { FromNowPipe } from '@app/shared/angular/from-now.pipe'
82import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
83import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component'
84import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component'
85import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component'
86import { VideoReportComponent } from '@app/shared/video/modals/video-report.component'
87import { ClipboardModule } from 'ngx-clipboard'
72 88
73@NgModule({ 89@NgModule({
74 imports: [ 90 imports: [
@@ -84,28 +100,50 @@ import { GlobalIconComponent } from '@app/shared/icons/global-icon.component'
84 NgbTabsetModule, 100 NgbTabsetModule,
85 NgbTooltipModule, 101 NgbTooltipModule,
86 102
103 ClipboardModule,
104
87 PrimeSharedModule, 105 PrimeSharedModule,
106 InputMaskModule,
88 NgPipesModule 107 NgPipesModule
89 ], 108 ],
90 109
91 declarations: [ 110 declarations: [
92 LoaderComponent, 111 LoaderComponent,
112 SmallLoaderComponent,
113
93 VideoThumbnailComponent, 114 VideoThumbnailComponent,
94 VideoMiniatureComponent, 115 VideoMiniatureComponent,
116 VideoPlaylistMiniatureComponent,
117 VideoAddToPlaylistComponent,
118 VideoPlaylistElementMiniatureComponent,
119 VideosSelectionComponent,
120 VideoActionsDropdownComponent,
121
122 VideoDownloadComponent,
123 VideoReportComponent,
124 VideoBlacklistComponent,
125
95 FeedComponent, 126 FeedComponent,
127
96 ButtonComponent, 128 ButtonComponent,
97 DeleteButtonComponent, 129 DeleteButtonComponent,
98 EditButtonComponent, 130 EditButtonComponent,
99 ActionDropdownComponent, 131
100 NumberFormatterPipe, 132 NumberFormatterPipe,
101 ObjectLengthPipe, 133 ObjectLengthPipe,
102 FromNowPipe, 134 FromNowPipe,
135 PeerTubeTemplateDirective,
136
137 ActionDropdownComponent,
103 MarkdownTextareaComponent, 138 MarkdownTextareaComponent,
104 InfiniteScrollerDirective, 139 InfiniteScrollerDirective,
105 TextareaAutoResizeDirective, 140 TextareaAutoResizeDirective,
106 HelpComponent, 141 HelpComponent,
142
107 ReactiveFileComponent, 143 ReactiveFileComponent,
108 PeertubeCheckboxComponent, 144 PeertubeCheckboxComponent,
145 TimestampInputComponent,
146
109 SubscribeButtonComponent, 147 SubscribeButtonComponent,
110 RemoteSubscribeComponent, 148 RemoteSubscribeComponent,
111 InstanceFeaturesTableComponent, 149 InstanceFeaturesTableComponent,
@@ -114,7 +152,9 @@ import { GlobalIconComponent } from '@app/shared/icons/global-icon.component'
114 TopMenuDropdownComponent, 152 TopMenuDropdownComponent,
115 UserNotificationsComponent, 153 UserNotificationsComponent,
116 ConfirmComponent, 154 ConfirmComponent,
117 GlobalIconComponent 155
156 GlobalIconComponent,
157 ImageUploadComponent
118 ], 158 ],
119 159
120 exports: [ 160 exports: [
@@ -130,24 +170,44 @@ import { GlobalIconComponent } from '@app/shared/icons/global-icon.component'
130 NgbTabsetModule, 170 NgbTabsetModule,
131 NgbTooltipModule, 171 NgbTooltipModule,
132 172
173 ClipboardModule,
174
133 PrimeSharedModule, 175 PrimeSharedModule,
176 InputMaskModule,
134 BytesPipe, 177 BytesPipe,
135 KeysPipe, 178 KeysPipe,
136 179
137 LoaderComponent, 180 LoaderComponent,
181 SmallLoaderComponent,
182
138 VideoThumbnailComponent, 183 VideoThumbnailComponent,
139 VideoMiniatureComponent, 184 VideoMiniatureComponent,
185 VideoPlaylistMiniatureComponent,
186 VideoAddToPlaylistComponent,
187 VideoPlaylistElementMiniatureComponent,
188 VideosSelectionComponent,
189 VideoActionsDropdownComponent,
190
191 VideoDownloadComponent,
192 VideoReportComponent,
193 VideoBlacklistComponent,
194
140 FeedComponent, 195 FeedComponent,
196
141 ButtonComponent, 197 ButtonComponent,
142 DeleteButtonComponent, 198 DeleteButtonComponent,
143 EditButtonComponent, 199 EditButtonComponent,
200
144 ActionDropdownComponent, 201 ActionDropdownComponent,
145 MarkdownTextareaComponent, 202 MarkdownTextareaComponent,
146 InfiniteScrollerDirective, 203 InfiniteScrollerDirective,
147 TextareaAutoResizeDirective, 204 TextareaAutoResizeDirective,
148 HelpComponent, 205 HelpComponent,
206
149 ReactiveFileComponent, 207 ReactiveFileComponent,
150 PeertubeCheckboxComponent, 208 PeertubeCheckboxComponent,
209 TimestampInputComponent,
210
151 SubscribeButtonComponent, 211 SubscribeButtonComponent,
152 RemoteSubscribeComponent, 212 RemoteSubscribeComponent,
153 InstanceFeaturesTableComponent, 213 InstanceFeaturesTableComponent,
@@ -156,11 +216,14 @@ import { GlobalIconComponent } from '@app/shared/icons/global-icon.component'
156 TopMenuDropdownComponent, 216 TopMenuDropdownComponent,
157 UserNotificationsComponent, 217 UserNotificationsComponent,
158 ConfirmComponent, 218 ConfirmComponent,
219
159 GlobalIconComponent, 220 GlobalIconComponent,
221 ImageUploadComponent,
160 222
161 NumberFormatterPipe, 223 NumberFormatterPipe,
162 ObjectLengthPipe, 224 ObjectLengthPipe,
163 FromNowPipe 225 FromNowPipe,
226 PeerTubeTemplateDirective
164 ], 227 ],
165 228
166 providers: [ 229 providers: [
@@ -174,6 +237,7 @@ import { GlobalIconComponent } from '@app/shared/icons/global-icon.component'
174 VideoService, 237 VideoService,
175 AccountService, 238 AccountService,
176 VideoChannelService, 239 VideoChannelService,
240 VideoPlaylistService,
177 VideoCaptionService, 241 VideoCaptionService,
178 VideoImportService, 242 VideoImportService,
179 UserSubscriptionService, 243 UserSubscriptionService,
@@ -183,6 +247,7 @@ import { GlobalIconComponent } from '@app/shared/icons/global-icon.component'
183 LoginValidatorsService, 247 LoginValidatorsService,
184 ResetPasswordValidatorsService, 248 ResetPasswordValidatorsService,
185 UserValidatorsService, 249 UserValidatorsService,
250 VideoPlaylistValidatorsService,
186 VideoAbuseValidatorsService, 251 VideoAbuseValidatorsService,
187 VideoChannelValidatorsService, 252 VideoChannelValidatorsService,
188 VideoCommentValidatorsService, 253 VideoCommentValidatorsService,
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.ts b/client/src/app/shared/user-subscription/subscribe-button.component.ts
index 8f1754c7f..ef470ee44 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.ts
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.ts
@@ -38,7 +38,7 @@ export class SubscribeButtonComponent implements OnInit {
38 38
39 ngOnInit () { 39 ngOnInit () {
40 if (this.isUserLoggedIn()) { 40 if (this.isUserLoggedIn()) {
41 this.userSubscriptionService.isSubscriptionExists(this.uri) 41 this.userSubscriptionService.doesSubscriptionExist(this.uri)
42 .subscribe( 42 .subscribe(
43 res => this.subscribed = res[this.uri], 43 res => this.subscribed = res[this.uri],
44 44
diff --git a/client/src/app/shared/user-subscription/user-subscription.service.ts b/client/src/app/shared/user-subscription/user-subscription.service.ts
index 3d05f071e..cfd5b100f 100644
--- a/client/src/app/shared/user-subscription/user-subscription.service.ts
+++ b/client/src/app/shared/user-subscription/user-subscription.service.ts
@@ -28,7 +28,7 @@ export class UserSubscriptionService {
28 this.existsObservable = this.existsSubject.pipe( 28 this.existsObservable = this.existsSubject.pipe(
29 bufferTime(500), 29 bufferTime(500),
30 filter(uris => uris.length !== 0), 30 filter(uris => uris.length !== 0),
31 switchMap(uris => this.areSubscriptionExist(uris)), 31 switchMap(uris => this.doSubscriptionsExist(uris)),
32 share() 32 share()
33 ) 33 )
34 } 34 }
@@ -69,13 +69,13 @@ export class UserSubscriptionService {
69 ) 69 )
70 } 70 }
71 71
72 isSubscriptionExists (nameWithHost: string) { 72 doesSubscriptionExist (nameWithHost: string) {
73 this.existsSubject.next(nameWithHost) 73 this.existsSubject.next(nameWithHost)
74 74
75 return this.existsObservable.pipe(first()) 75 return this.existsObservable.pipe(first())
76 } 76 }
77 77
78 private areSubscriptionExist (uris: string[]): Observable<SubscriptionExistResult> { 78 private doSubscriptionsExist (uris: string[]): Observable<SubscriptionExistResult> {
79 const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist' 79 const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist'
80 let params = new HttpParams() 80 let params = new HttpParams()
81 81
diff --git a/client/src/app/shared/users/user-notification.model.ts b/client/src/app/shared/users/user-notification.model.ts
index 5d0dc19ae..72fc3e7b4 100644
--- a/client/src/app/shared/users/user-notification.model.ts
+++ b/client/src/app/shared/users/user-notification.model.ts
@@ -1,4 +1,4 @@
1import { UserNotification as UserNotificationServer, UserNotificationType, VideoInfo, ActorInfo } from '../../../../../shared' 1import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '../../../../../shared'
2import { Actor } from '@app/shared/actor/actor.model' 2import { Actor } from '@app/shared/actor/actor.model'
3 3
4export class UserNotification implements UserNotificationServer { 4export class UserNotification implements UserNotificationServer {
@@ -39,6 +39,7 @@ export class UserNotification implements UserNotificationServer {
39 39
40 actorFollow?: { 40 actorFollow?: {
41 id: number 41 id: number
42 state: FollowState
42 follower: ActorInfo & { avatarUrl?: string } 43 follower: ActorInfo & { avatarUrl?: string }
43 following: { 44 following: {
44 type: 'account' | 'channel' 45 type: 'account' | 'channel'
@@ -54,9 +55,11 @@ export class UserNotification implements UserNotificationServer {
54 videoUrl?: string 55 videoUrl?: string
55 commentUrl?: any[] 56 commentUrl?: any[]
56 videoAbuseUrl?: string 57 videoAbuseUrl?: string
58 videoAutoBlacklistUrl?: string
57 accountUrl?: string 59 accountUrl?: string
58 videoImportIdentifier?: string 60 videoImportIdentifier?: string
59 videoImportUrl?: string 61 videoImportUrl?: string
62 instanceFollowUrl?: string
60 63
61 constructor (hash: UserNotificationServer) { 64 constructor (hash: UserNotificationServer) {
62 this.id = hash.id 65 this.id = hash.id
@@ -107,6 +110,11 @@ export class UserNotification implements UserNotificationServer {
107 this.videoUrl = this.buildVideoUrl(this.videoAbuse.video) 110 this.videoUrl = this.buildVideoUrl(this.videoAbuse.video)
108 break 111 break
109 112
113 case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS:
114 this.videoAutoBlacklistUrl = '/admin/moderation/video-auto-blacklist/list'
115 this.videoUrl = this.buildVideoUrl(this.video)
116 break
117
110 case UserNotificationType.BLACKLIST_ON_MY_VIDEO: 118 case UserNotificationType.BLACKLIST_ON_MY_VIDEO:
111 this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video) 119 this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video)
112 break 120 break
@@ -118,7 +126,8 @@ export class UserNotification implements UserNotificationServer {
118 case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS: 126 case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS:
119 this.videoImportUrl = this.buildVideoImportUrl() 127 this.videoImportUrl = this.buildVideoImportUrl()
120 this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport) 128 this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
121 this.videoUrl = this.buildVideoUrl(this.videoImport.video) 129
130 if (this.videoImport.video) this.videoUrl = this.buildVideoUrl(this.videoImport.video)
122 break 131 break
123 132
124 case UserNotificationType.MY_VIDEO_IMPORT_ERROR: 133 case UserNotificationType.MY_VIDEO_IMPORT_ERROR:
@@ -133,6 +142,10 @@ export class UserNotification implements UserNotificationServer {
133 case UserNotificationType.NEW_FOLLOW: 142 case UserNotificationType.NEW_FOLLOW:
134 this.accountUrl = this.buildAccountUrl(this.actorFollow.follower) 143 this.accountUrl = this.buildAccountUrl(this.actorFollow.follower)
135 break 144 break
145
146 case UserNotificationType.NEW_INSTANCE_FOLLOWER:
147 this.instanceFollowUrl = '/admin/follows/followers-list'
148 break
136 } 149 }
137 } catch (err) { 150 } catch (err) {
138 console.error(err) 151 console.error(err)
diff --git a/client/src/app/shared/users/user-notification.service.ts b/client/src/app/shared/users/user-notification.service.ts
index f8a30955d..ae0bc9cb1 100644
--- a/client/src/app/shared/users/user-notification.service.ts
+++ b/client/src/app/shared/users/user-notification.service.ts
@@ -7,7 +7,7 @@ import { ResultList, UserNotification as UserNotificationServer, UserNotificatio
7import { UserNotification } from './user-notification.model' 7import { UserNotification } from './user-notification.model'
8import { AuthService } from '../../core' 8import { AuthService } from '../../core'
9import { ComponentPagination } from '../rest/component-pagination.model' 9import { ComponentPagination } from '../rest/component-pagination.model'
10import { User } from '..' 10import { User } from '../users/user.model'
11import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' 11import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
12 12
13@Injectable() 13@Injectable()
diff --git a/client/src/app/shared/users/user-notifications.component.html b/client/src/app/shared/users/user-notifications.component.html
index 0d69e0feb..d0d9d9f35 100644
--- a/client/src/app/shared/users/user-notifications.component.html
+++ b/client/src/app/shared/users/user-notifications.component.html
@@ -36,6 +36,14 @@
36 </div> 36 </div>
37 </ng-container> 37 </ng-container>
38 38
39 <ng-container i18n *ngSwitchCase="UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS">
40 <my-global-icon iconName="no"></my-global-icon>
41
42 <div class="message">
43 The recently added video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been <a (click)="markAsRead(notification)" [routerLink]="notification.videoAutoBlacklistUrl">auto-blacklisted</a>
44 </div>
45 </ng-container>
46
39 <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO"> 47 <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO">
40 <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" /> 48 <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
41 49
@@ -56,7 +64,7 @@
56 <my-global-icon iconName="cloud-download"></my-global-icon> 64 <my-global-icon iconName="cloud-download"></my-global-icon>
57 65
58 <div class="message"> 66 <div class="message">
59 <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded 67 <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl || notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded
60 </div> 68 </div>
61 </ng-container> 69 </ng-container>
62 70
@@ -94,6 +102,15 @@
94 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> mentioned you on <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">video {{ notification.comment.video.name }}</a> 102 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> mentioned you on <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">video {{ notification.comment.video.name }}</a>
95 </div> 103 </div>
96 </ng-container> 104 </ng-container>
105
106 <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_INSTANCE_FOLLOWER">
107 <my-global-icon iconName="users"></my-global-icon>
108
109 <div class="message">
110 Your instance has <a (click)="markAsRead(notification)" [routerLink]="notification.instanceFollowUrl">a new follower</a> ({{ notification.actorFollow.follower.host }})
111 <ng-container *ngIf="notification.actorFollow.state === 'pending'"> awaiting your approval</ng-container>
112 </div>
113 </ng-container>
97 </ng-container> 114 </ng-container>
98 115
99 <div class="from-date">{{ notification.createdAt | myFromNow }}</div> 116 <div class="from-date">{{ notification.createdAt | myFromNow }}</div>
diff --git a/client/src/app/shared/users/user-notifications.component.scss b/client/src/app/shared/users/user-notifications.component.scss
index 315d504c9..88f38d9cf 100644
--- a/client/src/app/shared/users/user-notifications.component.scss
+++ b/client/src/app/shared/users/user-notifications.component.scss
@@ -13,7 +13,7 @@
13 align-items: center; 13 align-items: center;
14 font-size: inherit; 14 font-size: inherit;
15 padding: 15px 5px 15px 10px; 15 padding: 15px 5px 15px 10px;
16 border-bottom: 1px solid rgba(0, 0, 0, 0.10); 16 border-bottom: 1px solid $separator-border-color;
17 17
18 &.unread { 18 &.unread {
19 background-color: rgba(0, 0, 0, 0.05); 19 background-color: rgba(0, 0, 0, 0.05);
diff --git a/client/src/app/shared/users/user-notifications.component.ts b/client/src/app/shared/users/user-notifications.component.ts
index b5f9fd399..ce43b604a 100644
--- a/client/src/app/shared/users/user-notifications.component.ts
+++ b/client/src/app/shared/users/user-notifications.component.ts
@@ -1,4 +1,4 @@
1import { Component, Input, OnInit } from '@angular/core' 1import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
2import { UserNotificationService } from '@app/shared/users/user-notification.service' 2import { UserNotificationService } from '@app/shared/users/user-notification.service'
3import { UserNotificationType } from '../../../../../shared' 3import { UserNotificationType } from '../../../../../shared'
4import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model' 4import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
@@ -15,6 +15,8 @@ export class UserNotificationsComponent implements OnInit {
15 @Input() infiniteScroll = true 15 @Input() infiniteScroll = true
16 @Input() itemsPerPage = 20 16 @Input() itemsPerPage = 20
17 17
18 @Output() notificationsLoaded = new EventEmitter()
19
18 notifications: UserNotification[] = [] 20 notifications: UserNotification[] = []
19 21
20 // So we can access it in the template 22 // So we can access it in the template
@@ -43,6 +45,8 @@ export class UserNotificationsComponent implements OnInit {
43 result => { 45 result => {
44 this.notifications = this.notifications.concat(result.data) 46 this.notifications = this.notifications.concat(result.data)
45 this.componentPagination.totalItems = result.total 47 this.componentPagination.totalItems = result.total
48
49 this.notificationsLoaded.emit()
46 }, 50 },
47 51
48 err => this.notifier.error(err.message) 52 err => this.notifier.error(err.message)
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts
index c15f1de8c..e3ed2dfbd 100644
--- a/client/src/app/shared/users/user.model.ts
+++ b/client/src/app/shared/users/user.model.ts
@@ -2,15 +2,18 @@ import { hasUserRight, User as UserServerModel, UserNotificationSetting, UserRig
2import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' 2import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type'
3import { Account } from '@app/shared/account/account.model' 3import { Account } from '@app/shared/account/account.model'
4import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 4import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
5import { UserAdminFlag } from '@shared/models/users/user-flag.model'
5 6
6export class User implements UserServerModel { 7export class User implements UserServerModel {
7 id: number 8 id: number
8 username: string 9 username: string
9 email: string 10 email: string
10 emailVerified: boolean 11 emailVerified: boolean
11 role: UserRole
12 nsfwPolicy: NSFWPolicyType 12 nsfwPolicy: NSFWPolicyType
13 13
14 role: UserRole
15 roleLabel: string
16
14 webTorrentEnabled: boolean 17 webTorrentEnabled: boolean
15 autoPlayVideo: boolean 18 autoPlayVideo: boolean
16 videosHistoryEnabled: boolean 19 videosHistoryEnabled: boolean
@@ -21,6 +24,8 @@ export class User implements UserServerModel {
21 videoChannels: VideoChannel[] 24 videoChannels: VideoChannel[]
22 createdAt: Date 25 createdAt: Date
23 26
27 adminFlags?: UserAdminFlag
28
24 blocked: boolean 29 blocked: boolean
25 blockedReason?: string 30 blockedReason?: string
26 31
@@ -30,6 +35,7 @@ export class User implements UserServerModel {
30 this.id = hash.id 35 this.id = hash.id
31 this.username = hash.username 36 this.username = hash.username
32 this.email = hash.email 37 this.email = hash.email
38
33 this.role = hash.role 39 this.role = hash.role
34 40
35 this.videoChannels = hash.videoChannels 41 this.videoChannels = hash.videoChannels
@@ -40,6 +46,9 @@ export class User implements UserServerModel {
40 this.videosHistoryEnabled = hash.videosHistoryEnabled 46 this.videosHistoryEnabled = hash.videosHistoryEnabled
41 this.autoPlayVideo = hash.autoPlayVideo 47 this.autoPlayVideo = hash.autoPlayVideo
42 this.createdAt = hash.createdAt 48 this.createdAt = hash.createdAt
49
50 this.adminFlags = hash.adminFlags
51
43 this.blocked = hash.blocked 52 this.blocked = hash.blocked
44 this.blockedReason = hash.blockedReason 53 this.blockedReason = hash.blockedReason
45 54
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 94e46d7c2..a9eab9b6f 100644
--- a/client/src/app/shared/video-blacklist/video-blacklist.service.ts
+++ b/client/src/app/shared/video-blacklist/video-blacklist.service.ts
@@ -1,11 +1,13 @@
1import { catchError, map } 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/components/common/sortmeta'
5import { Observable } from 'rxjs' 5import { from as observableFrom, Observable } from 'rxjs'
6import { VideoBlacklist, ResultList } from '../../../../../shared' 6import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared'
7import { Video } from '../video/video.model'
7import { environment } from '../../../environments/environment' 8import { environment } from '../../../environments/environment'
8import { RestExtractor, RestPagination, RestService } from '../rest' 9import { RestExtractor, RestPagination, RestService } from '../rest'
10import { ComponentPagination } from '../rest/component-pagination.model'
9 11
10@Injectable() 12@Injectable()
11export class VideoBlacklistService { 13export class VideoBlacklistService {
@@ -17,10 +19,14 @@ export class VideoBlacklistService {
17 private restExtractor: RestExtractor 19 private restExtractor: RestExtractor
18 ) {} 20 ) {}
19 21
20 listBlacklist (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoBlacklist>> { 22 listBlacklist (pagination: RestPagination, sort: SortMeta, type?: VideoBlacklistType): Observable<ResultList<VideoBlacklist>> {
21 let params = new HttpParams() 23 let params = new HttpParams()
22 params = this.restService.addRestGetParams(params, pagination, sort) 24 params = this.restService.addRestGetParams(params, pagination, sort)
23 25
26 if (type) {
27 params = params.set('type', type.toString())
28 }
29
24 return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlacklistService.BASE_VIDEOS_URL + 'blacklist', { params }) 30 return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlacklistService.BASE_VIDEOS_URL + 'blacklist', { params })
25 .pipe( 31 .pipe(
26 map(res => this.restExtractor.convertResultListDateToHuman(res)), 32 map(res => this.restExtractor.convertResultListDateToHuman(res)),
@@ -28,12 +34,37 @@ export class VideoBlacklistService {
28 ) 34 )
29 } 35 }
30 36
31 removeVideoFromBlacklist (videoId: number) { 37 getAutoBlacklistedAsVideoList (videoPagination: ComponentPagination): Observable<{ videos: Video[], totalVideos: number}> {
32 return this.authHttp.delete(VideoBlacklistService.BASE_VIDEOS_URL + videoId + '/blacklist') 38 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
33 .pipe( 39
34 map(this.restExtractor.extractDataBool), 40 // prioritize first created since waiting longest
35 catchError(res => this.restExtractor.handleError(res)) 41 const AUTO_BLACKLIST_SORT = 'createdAt'
36 ) 42
43 let params = new HttpParams()
44 params = this.restService.addRestGetParams(params, pagination, AUTO_BLACKLIST_SORT)
45
46 params = params.set('type', VideoBlacklistType.AUTO_BEFORE_PUBLISHED.toString())
47
48 return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlacklistService.BASE_VIDEOS_URL + 'blacklist', { params })
49 .pipe(
50 map(res => {
51 const videos = res.data.map(videoBlacklist => new Video(videoBlacklist.video))
52 const totalVideos = res.total
53 return { videos, totalVideos }
54 }),
55 catchError(res => this.restExtractor.handleError(res))
56 )
57 }
58
59 removeVideoFromBlacklist (videoIdArgs: number | number[]) {
60 const videoIds = Array.isArray(videoIdArgs) ? videoIdArgs : [ videoIdArgs ]
61
62 return observableFrom(videoIds)
63 .pipe(
64 concatMap(id => this.authHttp.delete(VideoBlacklistService.BASE_VIDEOS_URL + id + '/blacklist')),
65 toArray(),
66 catchError(err => this.restExtractor.handleError(err))
67 )
37 } 68 }
38 69
39 blacklistVideo (videoId: number, reason: string, unfederate: boolean) { 70 blacklistVideo (videoId: number, reason: string, unfederate: boolean) {
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 7ae66ddfc..7ae13154d 100644
--- a/client/src/app/shared/video-import/video-import.service.ts
+++ b/client/src/app/shared/video-import/video-import.service.ts
@@ -67,6 +67,7 @@ export class VideoImportService {
67 const description = video.description || null 67 const description = video.description || null
68 const support = video.support || null 68 const support = video.support || null
69 const scheduleUpdate = video.scheduleUpdate || null 69 const scheduleUpdate = video.scheduleUpdate || null
70 const originallyPublishedAt = video.originallyPublishedAt || null
70 71
71 return { 72 return {
72 name: video.name, 73 name: video.name,
@@ -81,9 +82,11 @@ export class VideoImportService {
81 nsfw: video.nsfw, 82 nsfw: video.nsfw,
82 waitTranscoding: video.waitTranscoding, 83 waitTranscoding: video.waitTranscoding,
83 commentsEnabled: video.commentsEnabled, 84 commentsEnabled: video.commentsEnabled,
85 downloadEnabled: video.downloadEnabled,
84 thumbnailfile: video.thumbnailfile, 86 thumbnailfile: video.thumbnailfile,
85 previewfile: video.previewfile, 87 previewfile: video.previewfile,
86 scheduleUpdate 88 scheduleUpdate,
89 originallyPublishedAt
87 } 90 }
88 } 91 }
89 92
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
new file mode 100644
index 000000000..648d580fa
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html
@@ -0,0 +1,76 @@
1<div class="root">
2 <div class="header">
3 <div class="first-row">
4 <div i18n class="title">Save to</div>
5
6 <div class="options" (click)="displayOptions = !displayOptions">
7 <my-global-icon iconName="cog"></my-global-icon>
8
9 <span i18n>Options</span>
10 </div>
11 </div>
12
13 <div class="options-row" *ngIf="displayOptions">
14 <div>
15 <my-peertube-checkbox
16 inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
17 i18n-labelText labelText="Start at"
18 ></my-peertube-checkbox>
19
20 <my-timestamp-input
21 [timestamp]="timestampOptions.startTimestamp"
22 [maxTimestamp]="video.duration"
23 [disabled]="!timestampOptions.startTimestampEnabled"
24 [(ngModel)]="timestampOptions.startTimestamp"
25 ></my-timestamp-input>
26 </div>
27
28 <div>
29 <my-peertube-checkbox
30 inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
31 i18n-labelText labelText="Stop at"
32 ></my-peertube-checkbox>
33
34 <my-timestamp-input
35 [timestamp]="timestampOptions.stopTimestamp"
36 [maxTimestamp]="video.duration"
37 [disabled]="!timestampOptions.stopTimestampEnabled"
38 [(ngModel)]="timestampOptions.stopTimestamp"
39 ></my-timestamp-input>
40 </div>
41 </div>
42 </div>
43
44 <div class="playlist dropdown-item" *ngFor="let playlist of videoPlaylists" (click)="togglePlaylist($event, playlist)">
45 <my-peertube-checkbox [inputName]="'in-playlist-' + playlist.id" [(ngModel)]="playlist.inPlaylist" [onPushWorkaround]="true"></my-peertube-checkbox>
46
47 <div class="display-name">
48 {{ playlist.displayName }}
49
50 <div *ngIf="playlist.inPlaylist && (playlist.startTimestamp || playlist.stopTimestamp)" class="timestamp-info">
51 {{ formatTimestamp(playlist) }}
52 </div>
53 </div>
54 </div>
55
56 <div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened">
57 <my-global-icon iconName="add"></my-global-icon>
58
59 Create a private playlist
60 </div>
61
62 <form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form">
63 <div class="form-group">
64 <label i18n for="displayName">Display name</label>
65 <input
66 type="text" id="displayName"
67 formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }"
68 >
69 <div *ngIf="formErrors['displayName']" class="form-error">
70 {{ formErrors['displayName'] }}
71 </div>
72 </div>
73
74 <input type="submit" i18n-value value="Create" [disabled]="!form.valid">
75 </form>
76</div>
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss
new file mode 100644
index 000000000..c677fea6c
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss
@@ -0,0 +1,107 @@
1@import '_variables';
2@import '_mixins';
3
4.root {
5 max-height: 300px;
6 overflow-y: auto;
7}
8
9.header {
10 min-width: 240px;
11 padding: 6px 24px 10px 24px;
12
13 margin-bottom: 10px;
14 border-bottom: 1px solid $separator-border-color;
15
16 .first-row {
17 display: flex;
18 align-items: center;
19
20 .title {
21 font-size: 18px;
22 flex-grow: 1;
23 }
24
25 .options {
26 display: flex;
27 align-items: center;
28 font-size: 14px;
29 cursor: pointer;
30
31 my-global-icon {
32 @include apply-svg-color(#333);
33
34 width: 16px;
35 height: 23px;
36 margin-right: 3px;
37 }
38 }
39 }
40
41 .options-row {
42 margin-top: 10px;
43 padding-left: 10px;
44
45 > div {
46 display: flex;
47 align-items: center;
48 }
49 }
50}
51
52.dropdown-item {
53 padding: 6px 24px;
54}
55
56.playlist {
57 display: flex;
58 cursor: pointer;
59
60 my-peertube-checkbox {
61 margin-right: 10px;
62 }
63
64 .display-name {
65 display: flex;
66 align-items: flex-end;
67
68 .timestamp-info {
69 font-size: 0.9em;
70 color: $grey-foreground-color;
71 margin-left: 5px;
72 }
73 }
74}
75
76.new-playlist-button,
77.new-playlist-block {
78 padding-top: 10px;
79 margin-top: 10px;
80 border-top: 1px solid $separator-border-color;
81}
82
83.new-playlist-button {
84 cursor: pointer;
85
86 my-global-icon {
87 @include apply-svg-color(#333);
88
89 position: relative;
90 left: -1px;
91 top: -1px;
92 margin-right: 4px;
93 width: 21px;
94 height: 21px;
95 }
96}
97
98input[type=text] {
99 @include peertube-input-text(200px);
100
101 display: block;
102}
103
104input[type=submit] {
105 @include peertube-button;
106 @include orange-button;
107}
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts
new file mode 100644
index 000000000..7dcdf7a9e
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts
@@ -0,0 +1,212 @@
1import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'
2import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
3import { AuthService, Notifier } from '@app/core'
4import { forkJoin } from 'rxjs'
5import { Video, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models'
6import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/forms'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { secondsToTime } from '../../../assets/player/utils'
9
10type PlaylistSummary = {
11 id: number
12 inPlaylist: boolean
13 displayName: string
14
15 startTimestamp?: number
16 stopTimestamp?: number
17}
18
19@Component({
20 selector: 'my-video-add-to-playlist',
21 styleUrls: [ './video-add-to-playlist.component.scss' ],
22 templateUrl: './video-add-to-playlist.component.html',
23 changeDetection: ChangeDetectionStrategy.OnPush
24})
25export class VideoAddToPlaylistComponent extends FormReactive implements OnInit {
26 @Input() video: Video
27 @Input() currentVideoTimestamp: number
28 @Input() lazyLoad = false
29
30 isNewPlaylistBlockOpened = false
31 videoPlaylists: PlaylistSummary[] = []
32 timestampOptions: {
33 startTimestampEnabled: boolean
34 startTimestamp: number
35 stopTimestampEnabled: boolean
36 stopTimestamp: number
37 }
38 displayOptions = false
39
40 constructor (
41 protected formValidatorService: FormValidatorService,
42 private authService: AuthService,
43 private notifier: Notifier,
44 private i18n: I18n,
45 private videoPlaylistService: VideoPlaylistService,
46 private videoPlaylistValidatorsService: VideoPlaylistValidatorsService,
47 private cd: ChangeDetectorRef
48 ) {
49 super()
50 }
51
52 get user () {
53 return this.authService.getUser()
54 }
55
56 ngOnInit () {
57 this.resetOptions(true)
58
59 this.buildForm({
60 displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME
61 })
62
63 if (this.lazyLoad !== true) this.load()
64 }
65
66 load () {
67 forkJoin([
68 this.videoPlaylistService.listAccountPlaylists(this.user.account, '-updatedAt'),
69 this.videoPlaylistService.doesVideoExistInPlaylist(this.video.id)
70 ])
71 .subscribe(
72 ([ playlistsResult, existResult ]) => {
73 for (const playlist of playlistsResult.data) {
74 const existingPlaylist = existResult[ this.video.id ].find(p => p.playlistId === playlist.id)
75
76 this.videoPlaylists.push({
77 id: playlist.id,
78 displayName: playlist.displayName,
79 inPlaylist: !!existingPlaylist,
80 startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined,
81 stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined
82 })
83 }
84
85 this.cd.markForCheck()
86 }
87 )
88 }
89
90 openChange (opened: boolean) {
91 if (opened === false) {
92 this.isNewPlaylistBlockOpened = false
93 this.displayOptions = false
94 }
95 }
96
97 openCreateBlock (event: Event) {
98 event.preventDefault()
99
100 this.isNewPlaylistBlockOpened = true
101 }
102
103 togglePlaylist (event: Event, playlist: PlaylistSummary) {
104 event.preventDefault()
105
106 if (playlist.inPlaylist === true) {
107 this.removeVideoFromPlaylist(playlist)
108 } else {
109 this.addVideoInPlaylist(playlist)
110 }
111
112 playlist.inPlaylist = !playlist.inPlaylist
113 this.resetOptions()
114
115 this.cd.markForCheck()
116 }
117
118 createPlaylist () {
119 const displayName = this.form.value[ 'displayName' ]
120
121 const videoPlaylistCreate: VideoPlaylistCreate = {
122 displayName,
123 privacy: VideoPlaylistPrivacy.PRIVATE
124 }
125
126 this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe(
127 res => {
128 this.videoPlaylists.push({
129 id: res.videoPlaylist.id,
130 displayName,
131 inPlaylist: false
132 })
133
134 this.isNewPlaylistBlockOpened = false
135
136 this.cd.markForCheck()
137 },
138
139 err => this.notifier.error(err.message)
140 )
141 }
142
143 resetOptions (resetTimestamp = false) {
144 this.displayOptions = false
145
146 this.timestampOptions = {} as any
147 this.timestampOptions.startTimestampEnabled = false
148 this.timestampOptions.stopTimestampEnabled = false
149
150 if (resetTimestamp) {
151 this.timestampOptions.startTimestamp = 0
152 this.timestampOptions.stopTimestamp = this.video.duration
153 }
154 }
155
156 formatTimestamp (playlist: PlaylistSummary) {
157 const start = playlist.startTimestamp ? secondsToTime(playlist.startTimestamp) : ''
158 const stop = playlist.stopTimestamp ? secondsToTime(playlist.stopTimestamp) : ''
159
160 return `(${start}-${stop})`
161 }
162
163 private removeVideoFromPlaylist (playlist: PlaylistSummary) {
164 this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, this.video.id)
165 .subscribe(
166 () => {
167 this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName }))
168
169 playlist.inPlaylist = false
170 },
171
172 err => {
173 this.notifier.error(err.message)
174
175 playlist.inPlaylist = true
176 },
177
178 () => this.cd.markForCheck()
179 )
180 }
181
182 private addVideoInPlaylist (playlist: PlaylistSummary) {
183 const body: VideoPlaylistElementCreate = { videoId: this.video.id }
184
185 if (this.timestampOptions.startTimestampEnabled) body.startTimestamp = this.timestampOptions.startTimestamp
186 if (this.timestampOptions.stopTimestampEnabled) body.stopTimestamp = this.timestampOptions.stopTimestamp
187
188 this.videoPlaylistService.addVideoInPlaylist(playlist.id, body)
189 .subscribe(
190 () => {
191 playlist.inPlaylist = true
192
193 playlist.startTimestamp = body.startTimestamp
194 playlist.stopTimestamp = body.stopTimestamp
195
196 const message = body.startTimestamp || body.stopTimestamp
197 ? this.i18n('Video added in {{n}} at timestamps {{t}}', { n: playlist.displayName, t: this.formatTimestamp(playlist) })
198 : this.i18n('Video added in {{n}}', { n: playlist.displayName })
199
200 this.notifier.success(message)
201 },
202
203 err => {
204 this.notifier.error(err.message)
205
206 playlist.inPlaylist = false
207 },
208
209 () => this.cd.markForCheck()
210 )
211 }
212}
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html
new file mode 100644
index 000000000..ab5a78928
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html
@@ -0,0 +1,73 @@
1<div class="video" [ngClass]="{ playing: playing }">
2 <a [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()">
3 <div class="position">
4 <my-global-icon *ngIf="playing" iconName="play"></my-global-icon>
5 <ng-container *ngIf="!playing">{{ position }}</ng-container>
6 </div>
7
8 <my-video-thumbnail
9 [video]="video" [nsfw]="isVideoBlur(video)"
10 [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
11 ></my-video-thumbnail>
12
13 <div class="video-info">
14 <a tabindex="-1" class="video-info-name"
15 [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
16 [attr.title]="video.name"
17 >{{ video.name }}</a>
18
19 <a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', video.byAccount ]">{{ video.byAccount }}</a>
20 <span *ngIf="!accountLink" tabindex="-1" class="video-info-account">{{ video.byAccount }}</span>
21
22 <span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(video) }}</span>
23 </div>
24 </a>
25
26 <div *ngIf="owned" class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom-right" (openChange)="onDropdownOpenChange()"
27 autoClose="outside">
28 <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button" class="icon-more" (click)="$event.preventDefault()"></my-global-icon>
29
30 <div ngbDropdownMenu>
31 <div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, video)">
32 <my-global-icon iconName="edit"></my-global-icon>
33 <ng-container i18n>Edit starts/stops at</ng-container>
34 </div>
35
36 <div class="timestamp-options" *ngIf="displayTimestampOptions">
37 <div>
38 <my-peertube-checkbox
39 inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
40 i18n-labelText labelText="Start at"
41 ></my-peertube-checkbox>
42
43 <my-timestamp-input
44 [timestamp]="timestampOptions.startTimestamp"
45 [maxTimestamp]="video.duration"
46 [disabled]="!timestampOptions.startTimestampEnabled"
47 [(ngModel)]="timestampOptions.startTimestamp"
48 ></my-timestamp-input>
49 </div>
50
51 <div>
52 <my-peertube-checkbox
53 inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
54 i18n-labelText labelText="Stop at"
55 ></my-peertube-checkbox>
56
57 <my-timestamp-input
58 [timestamp]="timestampOptions.stopTimestamp"
59 [maxTimestamp]="video.duration"
60 [disabled]="!timestampOptions.stopTimestampEnabled"
61 [(ngModel)]="timestampOptions.stopTimestamp"
62 ></my-timestamp-input>
63 </div>
64
65 <input type="submit" i18n-value value="Save" (click)="updateTimestamps(video)">
66 </div>
67
68 <span class="dropdown-item" (click)="removeFromPlaylist(video)">
69 <my-global-icon iconName="delete"></my-global-icon> <ng-container i18n>Delete from {{ playlist?.displayName }}</ng-container>
70 </span>
71 </div>
72 </div>
73</div>
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss
new file mode 100644
index 000000000..cb7072d7f
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss
@@ -0,0 +1,125 @@
1@import '_variables';
2@import '_mixins';
3@import '_miniature';
4
5my-video-thumbnail {
6 @include thumbnail-size-component(130px, 72px);
7
8 display: flex; // Avoids an issue with line-height that adds space below the element
9 margin-right: 10px;
10}
11
12.video {
13 display: flex;
14 align-items: center;
15 background-color: var(--mainBackgroundColor);
16 padding: 10px;
17 border-bottom: 1px solid $separator-border-color;
18
19 &:hover {
20 background-color: rgba(0, 0, 0, 0.05);
21
22 .more {
23 opacity: 1;
24 }
25 }
26
27 &.playing {
28 background-color: rgba(0, 0, 0, 0.02);
29 }
30
31 a {
32 @include disable-default-a-behaviour;
33
34 display: flex;
35 min-width: 0;
36 align-items: center;
37 cursor: pointer;
38
39 .position {
40 font-weight: $font-semibold;
41 margin-right: 10px;
42 color: $grey-foreground-color;
43 min-width: 25px;
44
45 my-global-icon {
46 @include apply-svg-color($grey-foreground-color);
47
48 width: 17px;
49 position: relative;
50 left: -2px;
51 }
52 }
53
54 .video-info {
55 display: flex;
56 flex-direction: column;
57 align-self: flex-start;
58 min-width: 0;
59
60 a {
61 color: var(--mainForegroundColor);
62 width: auto;
63
64 &:hover {
65 text-decoration: underline !important;
66 }
67 }
68
69 .video-info-name {
70 font-size: 18px;
71 font-weight: $font-semibold;
72 display: inline-block;
73
74 @include ellipsis;
75 }
76
77 .video-info-account, .video-info-timestamp {
78 color: $grey-foreground-color;
79 }
80 }
81 }
82
83 .more {
84 justify-self: flex-end;
85 margin-left: auto;
86 cursor: pointer;
87 opacity: 0;
88
89 &.show {
90 opacity: 1;
91 }
92
93 .icon-more {
94 @include apply-svg-color($grey-foreground-color);
95
96 display: flex;
97
98 &::after {
99 border: none;
100 }
101 }
102
103 .dropdown-item {
104 @include dropdown-with-icon-item;
105 }
106
107 .timestamp-options {
108 padding-top: 0;
109 padding-left: 35px;
110 margin-bottom: 15px;
111
112 > div {
113 display: flex;
114 align-items: center;
115 }
116
117 input {
118 @include peertube-button;
119 @include orange-button;
120
121 margin-top: 10px;
122 }
123 }
124 }
125}
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
new file mode 100644
index 000000000..57990707a
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
@@ -0,0 +1,159 @@
1import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'
2import { Video } from '@app/shared/video/video.model'
3import { VideoPlaylistElementUpdate } from '@shared/models'
4import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core'
5import { ActivatedRoute } from '@angular/router'
6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { VideoService } from '@app/shared/video/video.service'
8import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
9import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
10import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
11import { secondsToTime } from '../../../assets/player/utils'
12
13@Component({
14 selector: 'my-video-playlist-element-miniature',
15 styleUrls: [ './video-playlist-element-miniature.component.scss' ],
16 templateUrl: './video-playlist-element-miniature.component.html',
17 changeDetection: ChangeDetectionStrategy.OnPush
18})
19export class VideoPlaylistElementMiniatureComponent {
20 @ViewChild('moreDropdown') moreDropdown: NgbDropdown
21
22 @Input() playlist: VideoPlaylist
23 @Input() video: Video
24 @Input() owned = false
25 @Input() playing = false
26 @Input() rowLink = false
27 @Input() accountLink = true
28 @Input() position: number
29
30 @Output() elementRemoved = new EventEmitter<Video>()
31
32 displayTimestampOptions = false
33
34 timestampOptions: {
35 startTimestampEnabled: boolean
36 startTimestamp: number
37 stopTimestampEnabled: boolean
38 stopTimestamp: number
39 } = {} as any
40
41 constructor (
42 private authService: AuthService,
43 private serverService: ServerService,
44 private notifier: Notifier,
45 private confirmService: ConfirmService,
46 private route: ActivatedRoute,
47 private i18n: I18n,
48 private videoService: VideoService,
49 private videoPlaylistService: VideoPlaylistService,
50 private cdr: ChangeDetectorRef
51 ) {}
52
53 buildRouterLink () {
54 if (!this.playlist) return null
55
56 return [ '/videos/watch/playlist', this.playlist.uuid ]
57 }
58
59 buildRouterQuery () {
60 if (!this.video) return {}
61
62 return {
63 videoId: this.video.uuid,
64 start: this.video.playlistElement.startTimestamp,
65 stop: this.video.playlistElement.stopTimestamp
66 }
67 }
68
69 isVideoBlur (video: Video) {
70 return video.isVideoNSFWForUser(this.authService.getUser(), this.serverService.getConfig())
71 }
72
73 removeFromPlaylist (video: Video) {
74 this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, video.id)
75 .subscribe(
76 () => {
77 this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName }))
78
79 this.elementRemoved.emit(this.video)
80 },
81
82 err => this.notifier.error(err.message)
83 )
84
85 this.moreDropdown.close()
86 }
87
88 updateTimestamps (video: Video) {
89 const body: VideoPlaylistElementUpdate = {}
90
91 body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null
92 body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null
93
94 this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, video.id, body)
95 .subscribe(
96 () => {
97 this.notifier.success(this.i18n('Timestamps updated'))
98
99 video.playlistElement.startTimestamp = body.startTimestamp
100 video.playlistElement.stopTimestamp = body.stopTimestamp
101
102 this.cdr.detectChanges()
103 },
104
105 err => this.notifier.error(err.message)
106 )
107
108 this.moreDropdown.close()
109 }
110
111 formatTimestamp (video: Video) {
112 const start = video.playlistElement.startTimestamp
113 const stop = video.playlistElement.stopTimestamp
114
115 const startFormatted = secondsToTime(start, true, ':')
116 const stopFormatted = secondsToTime(stop, true, ':')
117
118 if (start === null && stop === null) return ''
119
120 if (start !== null && stop === null) return this.i18n('Starts at ') + startFormatted
121 if (start === null && stop !== null) return this.i18n('Stops at ') + stopFormatted
122
123 return this.i18n('Starts at ') + startFormatted + this.i18n(' and stops at ') + stopFormatted
124 }
125
126 onDropdownOpenChange () {
127 this.displayTimestampOptions = false
128 }
129
130 toggleDisplayTimestampsOptions (event: Event, video: Video) {
131 event.preventDefault()
132
133 this.displayTimestampOptions = !this.displayTimestampOptions
134
135 if (this.displayTimestampOptions === true) {
136 this.timestampOptions = {
137 startTimestampEnabled: false,
138 stopTimestampEnabled: false,
139 startTimestamp: 0,
140 stopTimestamp: video.duration
141 }
142
143 if (video.playlistElement.startTimestamp) {
144 this.timestampOptions.startTimestampEnabled = true
145 this.timestampOptions.startTimestamp = video.playlistElement.startTimestamp
146 }
147
148 if (video.playlistElement.stopTimestamp) {
149 this.timestampOptions.stopTimestampEnabled = true
150 this.timestampOptions.stopTimestamp = video.playlistElement.stopTimestamp
151 }
152 }
153
154 // FIXME: why do we have to use setTimeout here?
155 setTimeout(() => {
156 this.cdr.detectChanges()
157 })
158 }
159}
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html
new file mode 100644
index 000000000..86f6664cb
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html
@@ -0,0 +1,34 @@
1<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage }">
2 <a
3 [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description"
4 class="miniature-thumbnail"
5 >
6 <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
7
8 <div class="miniature-playlist-info-overlay">
9 <ng-container i18n>{playlist.videosLength, plural, =0 {No videos} =1 {1 video} other {{{ playlist.videosLength }} videos}}</ng-container>
10 </div>
11
12 <div class="play-overlay">
13 <div class="icon"></div>
14 </div>
15 </a>
16
17 <div class="miniature-info">
18 <a tabindex="-1" class="miniature-name" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description">
19 {{ playlist.displayName }}
20 </a>
21
22 <a i18n [routerLink]="[ '/video-channels', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy">
23 {{ playlist.videoChannelBy }}
24 </a>
25
26 <div class="privacy-date">
27 <span class="video-info-privacy" *ngIf="displayPrivacy">{{ playlist.privacy.label }}</span>
28
29 <span i18n class="updated-at">Updated {{ playlist.updatedAt | myFromNow }}</span>
30 </div>
31
32 <div *ngIf="displayDescription" class="video-info-description">{{ playlist.description }}</div>
33 </div>
34</div>
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
new file mode 100644
index 000000000..8947e72d1
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
@@ -0,0 +1,78 @@
1@import '_variables';
2@import '_mixins';
3@import '_miniature';
4
5.miniature {
6 display: inline-block;
7
8 &.no-videos:not(.to-manage){
9 a {
10 cursor: default !important;
11 }
12 }
13
14 &.to-manage,
15 &.no-videos {
16 .play-overlay {
17 display: none;
18 }
19 }
20
21 .miniature-thumbnail {
22 @include miniature-thumbnail;
23
24 .miniature-playlist-info-overlay {
25 @include static-thumbnail-overlay;
26
27 position: absolute;
28 right: 0;
29 bottom: 0;
30 height: $video-thumbnail-height;
31 padding: 0 10px;
32 display: flex;
33 align-items: center;
34 font-size: 14px;
35 font-weight: $font-semibold;
36 }
37 }
38
39 .miniature-info {
40 width: 200px;
41 margin-top: 2px;
42 line-height: normal;
43
44 .miniature-name {
45 @include miniature-name;
46
47 @include ellipsis-multiline(1.3em, 2);
48
49 margin: 0;
50 }
51
52 .by {
53 @include disable-default-a-behaviour;
54
55 display: block;
56 color: $grey-foreground-color;
57 }
58
59 .privacy-date {
60 margin-top: 5px;
61
62 .video-info-privacy {
63 font-size: 14px;
64 font-weight: $font-semibold;
65
66 &::after {
67 content: '-';
68 margin: 0 3px;
69 }
70 }
71 }
72
73 .video-info-description {
74 margin-top: 10px;
75 color: $grey-foreground-color;
76 }
77 }
78}
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
new file mode 100644
index 000000000..523e96f2a
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
@@ -0,0 +1,22 @@
1import { Component, Input } from '@angular/core'
2import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
3
4@Component({
5 selector: 'my-video-playlist-miniature',
6 styleUrls: [ './video-playlist-miniature.component.scss' ],
7 templateUrl: './video-playlist-miniature.component.html'
8})
9export class VideoPlaylistMiniatureComponent {
10 @Input() playlist: VideoPlaylist
11 @Input() toManage = false
12 @Input() displayChannel = false
13 @Input() displayDescription = false
14 @Input() displayPrivacy = false
15
16 getPlaylistUrl () {
17 if (this.toManage) return [ '/my-account/video-playlists', this.playlist.uuid ]
18 if (this.playlist.videosLength === 0) return null
19
20 return [ '/videos/watch/playlist', this.playlist.uuid ]
21 }
22}
diff --git a/client/src/app/shared/video-playlist/video-playlist.model.ts b/client/src/app/shared/video-playlist/video-playlist.model.ts
new file mode 100644
index 000000000..7e311aa54
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist.model.ts
@@ -0,0 +1,84 @@
1import {
2 VideoChannelSummary,
3 VideoConstant,
4 VideoPlaylist as ServerVideoPlaylist,
5 VideoPlaylistPrivacy,
6 VideoPlaylistType
7} from '../../../../../shared/models/videos'
8import { AccountSummary, peertubeTranslate } from '@shared/models'
9import { Actor } from '@app/shared/actor/actor.model'
10import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
11
12export class VideoPlaylist implements ServerVideoPlaylist {
13 id: number
14 uuid: string
15 isLocal: boolean
16
17 displayName: string
18 description: string
19 privacy: VideoConstant<VideoPlaylistPrivacy>
20
21 thumbnailPath: string
22
23 videosLength: number
24
25 type: VideoConstant<VideoPlaylistType>
26
27 createdAt: Date | string
28 updatedAt: Date | string
29
30 ownerAccount: AccountSummary
31 videoChannel?: VideoChannelSummary
32
33 thumbnailUrl: string
34
35 ownerBy: string
36 ownerAvatarUrl: string
37
38 videoChannelBy?: string
39 videoChannelAvatarUrl?: string
40
41 constructor (hash: ServerVideoPlaylist, translations: {}) {
42 const absoluteAPIUrl = getAbsoluteAPIUrl()
43
44 this.id = hash.id
45 this.uuid = hash.uuid
46 this.isLocal = hash.isLocal
47
48 this.displayName = hash.displayName
49
50 this.description = hash.description
51 this.privacy = hash.privacy
52
53 this.thumbnailPath = hash.thumbnailPath
54
55 if (this.thumbnailPath) {
56 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
57 } else {
58 this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg'
59 }
60
61 this.videosLength = hash.videosLength
62
63 this.type = hash.type
64
65 this.createdAt = new Date(hash.createdAt)
66 this.updatedAt = new Date(hash.updatedAt)
67
68 this.ownerAccount = hash.ownerAccount
69 this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
70 this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount)
71
72 if (hash.videoChannel) {
73 this.videoChannel = hash.videoChannel
74 this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host)
75 this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.videoChannel)
76 }
77
78 this.privacy.label = peertubeTranslate(this.privacy.label, translations)
79
80 if (this.type.id === VideoPlaylistType.WATCH_LATER) {
81 this.displayName = peertubeTranslate(this.displayName, translations)
82 }
83 }
84}
diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts
new file mode 100644
index 000000000..da7437507
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist.service.ts
@@ -0,0 +1,179 @@
1import { bufferTime, catchError, filter, first, map, share, switchMap } from 'rxjs/operators'
2import { Injectable } from '@angular/core'
3import { Observable, ReplaySubject, Subject } from 'rxjs'
4import { RestExtractor } from '../rest/rest-extractor.service'
5import { HttpClient, HttpParams } from '@angular/common/http'
6import { ResultList, VideoPlaylistElementCreate, VideoPlaylistElementUpdate } from '../../../../../shared'
7import { environment } from '../../../environments/environment'
8import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model'
9import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
10import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
11import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
12import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model'
13import { objectToFormData } from '@app/shared/misc/utils'
14import { ServerService } from '@app/core'
15import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
16import { AccountService } from '@app/shared/account/account.service'
17import { Account } from '@app/shared/account/account.model'
18import { RestService } from '@app/shared/rest'
19import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model'
20import { VideoPlaylistReorder } from '@shared/models/videos/playlist/video-playlist-reorder.model'
21
22@Injectable()
23export class VideoPlaylistService {
24 static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
25 static MY_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/users/me/video-playlists/'
26
27 // Use a replay subject because we "next" a value before subscribing
28 private videoExistsInPlaylistSubject: Subject<number> = new ReplaySubject(1)
29 private readonly videoExistsInPlaylistObservable: Observable<VideoExistInPlaylist>
30
31 constructor (
32 private authHttp: HttpClient,
33 private serverService: ServerService,
34 private restExtractor: RestExtractor,
35 private restService: RestService
36 ) {
37 this.videoExistsInPlaylistObservable = this.videoExistsInPlaylistSubject.pipe(
38 bufferTime(500),
39 filter(videoIds => videoIds.length !== 0),
40 switchMap(videoIds => this.doVideosExistInPlaylist(videoIds)),
41 share()
42 )
43 }
44
45 listChannelPlaylists (videoChannel: VideoChannel): Observable<ResultList<VideoPlaylist>> {
46 const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
47
48 return this.authHttp.get<ResultList<VideoPlaylist>>(url)
49 .pipe(
50 switchMap(res => this.extractPlaylists(res)),
51 catchError(err => this.restExtractor.handleError(err))
52 )
53 }
54
55 listAccountPlaylists (account: Account, sort: string): Observable<ResultList<VideoPlaylist>> {
56 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
57
58 let params = new HttpParams()
59 params = this.restService.addRestGetParams(params, undefined, sort)
60
61 return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
62 .pipe(
63 switchMap(res => this.extractPlaylists(res)),
64 catchError(err => this.restExtractor.handleError(err))
65 )
66 }
67
68 getVideoPlaylist (id: string | number) {
69 const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id
70
71 return this.authHttp.get<VideoPlaylist>(url)
72 .pipe(
73 switchMap(res => this.extractPlaylist(res)),
74 catchError(err => this.restExtractor.handleError(err))
75 )
76 }
77
78 createVideoPlaylist (body: VideoPlaylistCreate) {
79 const data = objectToFormData(body)
80
81 return this.authHttp.post<{ videoPlaylist: { id: number } }>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data)
82 .pipe(
83 catchError(err => this.restExtractor.handleError(err))
84 )
85 }
86
87 updateVideoPlaylist (videoPlaylist: VideoPlaylist, body: VideoPlaylistUpdate) {
88 const data = objectToFormData(body)
89
90 return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id, data)
91 .pipe(
92 map(this.restExtractor.extractDataBool),
93 catchError(err => this.restExtractor.handleError(err))
94 )
95 }
96
97 removeVideoPlaylist (videoPlaylist: VideoPlaylist) {
98 return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id)
99 .pipe(
100 map(this.restExtractor.extractDataBool),
101 catchError(err => this.restExtractor.handleError(err))
102 )
103 }
104
105 addVideoInPlaylist (playlistId: number, body: VideoPlaylistElementCreate) {
106 return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos', body)
107 .pipe(
108 map(this.restExtractor.extractDataBool),
109 catchError(err => this.restExtractor.handleError(err))
110 )
111 }
112
113 updateVideoOfPlaylist (playlistId: number, videoId: number, body: VideoPlaylistElementUpdate) {
114 return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId, body)
115 .pipe(
116 map(this.restExtractor.extractDataBool),
117 catchError(err => this.restExtractor.handleError(err))
118 )
119 }
120
121 removeVideoFromPlaylist (playlistId: number, videoId: number) {
122 return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId)
123 .pipe(
124 map(this.restExtractor.extractDataBool),
125 catchError(err => this.restExtractor.handleError(err))
126 )
127 }
128
129 reorderPlaylist (playlistId: number, oldPosition: number, newPosition: number) {
130 const body: VideoPlaylistReorder = {
131 startPosition: oldPosition,
132 insertAfterPosition: newPosition
133 }
134
135 return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/reorder', body)
136 .pipe(
137 map(this.restExtractor.extractDataBool),
138 catchError(err => this.restExtractor.handleError(err))
139 )
140 }
141
142 doesVideoExistInPlaylist (videoId: number) {
143 this.videoExistsInPlaylistSubject.next(videoId)
144
145 return this.videoExistsInPlaylistObservable.pipe(first())
146 }
147
148 extractPlaylists (result: ResultList<VideoPlaylistServerModel>) {
149 return this.serverService.localeObservable
150 .pipe(
151 map(translations => {
152 const playlistsJSON = result.data
153 const total = result.total
154 const playlists: VideoPlaylist[] = []
155
156 for (const playlistJSON of playlistsJSON) {
157 playlists.push(new VideoPlaylist(playlistJSON, translations))
158 }
159
160 return { data: playlists, total }
161 })
162 )
163 }
164
165 extractPlaylist (playlist: VideoPlaylistServerModel) {
166 return this.serverService.localeObservable
167 .pipe(map(translations => new VideoPlaylist(playlist, translations)))
168 }
169
170 private doVideosExistInPlaylist (videoIds: number[]): Observable<VideoExistInPlaylist> {
171 const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist'
172 let params = new HttpParams()
173
174 params = this.restService.addObjectParams(params, { videoIds })
175
176 return this.authHttp.get<VideoExistInPlaylist>(url, { params })
177 .pipe(catchError(err => this.restExtractor.handleError(err)))
178 }
179}
diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html
index 1f97bc389..268677977 100644
--- a/client/src/app/shared/video/abstract-video-list.html
+++ b/client/src/app/shared/video/abstract-video-list.html
@@ -1,4 +1,4 @@
1<div [ngClass]="{ 'margin-content': marginContent }"> 1<div class="margin-content">
2 <div class="videos-header"> 2 <div class="videos-header">
3 <div *ngIf="titlePage" class="title-page title-page-single"> 3 <div *ngIf="titlePage" class="title-page title-page-single">
4 <div placement="bottom" [ngbTooltip]="titleTooltip" container="body"> 4 <div placement="bottom" [ngbTooltip]="titleTooltip" container="body">
@@ -11,7 +11,7 @@
11 <div class="moderation-block" *ngIf="displayModerationBlock"> 11 <div class="moderation-block" *ngIf="displayModerationBlock">
12 <my-peertube-checkbox 12 <my-peertube-checkbox
13 (change)="toggleModerationDisplay()" 13 (change)="toggleModerationDisplay()"
14 inputName="display-unlisted-private" i18n-labelText labelText="Display unlisted and private videos" 14 inputName="display-unlisted-private" i18n-labelText labelText="Display unlisted and private videos"
15 > 15 >
16 </my-peertube-checkbox> 16 </my-peertube-checkbox>
17 </div> 17 </div>
@@ -19,13 +19,14 @@
19 19
20 <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div> 20 <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div>
21 <div 21 <div
22 myInfiniteScroller 22 myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true"
23 [pageHeight]="pageHeight" [firstLoadedPage]="firstLoadedPage" 23 class="videos"
24 (nearOfTop)="onNearOfTop()" (nearOfBottom)="onNearOfBottom()" (pageChanged)="onPageChanged($event)"
25 class="videos" #videosElement
26 > 24 >
27 <div *ngFor="let videos of videoPages; trackBy: pageByVideoId" class="videos-page"> 25 <my-video-miniature
28 <my-video-miniature *ngFor="let video of videos; trackBy: videoById" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType"></my-video-miniature> 26 *ngFor="let video of videos; trackBy: videoById" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType"
29 </div> 27 [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions"
28 (videoBlacklisted)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)"
29 >
30 </my-video-miniature>
30 </div> 31 </div>
31</div> 32</div>
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss
index 292ede698..9d481d6e4 100644
--- a/client/src/app/shared/video/abstract-video-list.scss
+++ b/client/src/app/shared/video/abstract-video-list.scss
@@ -1,12 +1,5 @@
1@import '_mixins'; 1@import '_mixins';
2 2@import '_miniature';
3.videos {
4 text-align: center;
5
6 my-video-miniature {
7 text-align: left;
8 }
9}
10 3
11.videos-header { 4.videos-header {
12 display: flex; 5 display: flex;
@@ -31,8 +24,33 @@
31 } 24 }
32} 25}
33 26
34@media screen and (max-width: 500px) { 27.margin-content {
35 .videos { 28 width: $video-miniature-width * 6;
36 @include video-miniature-small-screen; 29 margin: auto !important;
30
31 @media screen and (max-width: 1800px) {
32 width: $video-miniature-width * 5;
33 }
34
35 @media screen and (max-width: 1800px - $video-miniature-width) {
36 width: $video-miniature-width * 4;
37 }
38
39 @media screen and (max-width: 1800px - (2* $video-miniature-width)) {
40 width: $video-miniature-width * 3;
41 }
42
43 @media screen and (max-width: 1800px - (3* $video-miniature-width)) {
44 width: $video-miniature-width * 2;
45 }
46
47 @media screen and (max-width: 500px) {
48 width: auto;
49 margin: 0 !important;
50
51 .videos {
52 @include video-miniature-small-screen;
53 }
37 } 54 }
38} 55}
56
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts
index b0633be4a..fa9d38735 100644
--- a/client/src/app/shared/video/abstract-video-list.ts
+++ b/client/src/app/shared/video/abstract-video-list.ts
@@ -1,66 +1,62 @@
1import { debounceTime } from 'rxjs/operators' 1import { debounceTime } from 'rxjs/operators'
2import { ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core' 2import { OnDestroy, OnInit } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { Location } from '@angular/common'
5import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
6import { fromEvent, Observable, Subscription } from 'rxjs' 4import { fromEvent, Observable, Subscription } from 'rxjs'
7import { AuthService } from '../../core/auth' 5import { AuthService } from '../../core/auth'
8import { ComponentPagination } from '../rest/component-pagination.model' 6import { ComponentPagination } from '../rest/component-pagination.model'
9import { VideoSortField } from './sort-field.type' 7import { VideoSortField } from './sort-field.type'
10import { Video } from './video.model' 8import { Video } from './video.model'
11import { I18n } from '@ngx-translate/i18n-polyfill'
12import { ScreenService } from '@app/shared/misc/screen.service' 9import { ScreenService } from '@app/shared/misc/screen.service'
13import { OwnerDisplayType } from '@app/shared/video/video-miniature.component' 10import { MiniatureDisplayOptions, OwnerDisplayType } from '@app/shared/video/video-miniature.component'
14import { Syndication } from '@app/shared/video/syndication.model' 11import { Syndication } from '@app/shared/video/syndication.model'
15import { Notifier } from '@app/core' 12import { Notifier, ServerService } from '@app/core'
16 13import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
17export abstract class AbstractVideoList implements OnInit, OnDestroy {
18 private static LINES_PER_PAGE = 4
19
20 @ViewChild('videosElement') videosElement: ElementRef
21 @ViewChild(InfiniteScrollerDirective) infiniteScroller: InfiniteScrollerDirective
22 14
15export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableForReuseHook {
23 pagination: ComponentPagination = { 16 pagination: ComponentPagination = {
24 currentPage: 1, 17 currentPage: 1,
25 itemsPerPage: 10, 18 itemsPerPage: 25,
26 totalItems: null 19 totalItems: null
27 } 20 }
28 sort: VideoSortField = '-publishedAt' 21 sort: VideoSortField = '-publishedAt'
22
29 categoryOneOf?: number 23 categoryOneOf?: number
30 defaultSort: VideoSortField = '-publishedAt' 24 defaultSort: VideoSortField = '-publishedAt'
25
31 syndicationItems: Syndication[] = [] 26 syndicationItems: Syndication[] = []
32 27
33 loadOnInit = true 28 loadOnInit = true
34 marginContent = true 29 videos: Video[] = []
35 pageHeight: number
36 videoWidth: number
37 videoHeight: number
38 videoPages: Video[][] = []
39 ownerDisplayType: OwnerDisplayType = 'account' 30 ownerDisplayType: OwnerDisplayType = 'account'
40 firstLoadedPage: number
41 displayModerationBlock = false 31 displayModerationBlock = false
42 titleTooltip: string 32 titleTooltip: string
33 displayVideoActions = true
43 34
44 protected baseVideoWidth = 215 35 disabled = false
45 protected baseVideoHeight = 205 36
37 displayOptions: MiniatureDisplayOptions = {
38 date: true,
39 views: true,
40 by: true,
41 privacyLabel: true,
42 privacyText: false,
43 state: false,
44 blacklistInfo: false
45 }
46 46
47 protected abstract notifier: Notifier 47 protected abstract notifier: Notifier
48 protected abstract authService: AuthService 48 protected abstract authService: AuthService
49 protected abstract router: Router
50 protected abstract route: ActivatedRoute 49 protected abstract route: ActivatedRoute
50 protected abstract serverService: ServerService
51 protected abstract screenService: ScreenService 51 protected abstract screenService: ScreenService
52 protected abstract i18n: I18n 52 protected abstract router: Router
53 protected abstract location: Location
54 protected abstract currentRoute: string
55 abstract titlePage: string 53 abstract titlePage: string
56 54
57 protected loadedPages: { [ id: number ]: Video[] } = {}
58 protected loadingPage: { [ id: number ]: boolean } = {}
59 protected otherRouteParams = {}
60
61 private resizeSubscription: Subscription 55 private resizeSubscription: Subscription
56 private angularState: number
57
58 abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number }>
62 59
63 abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number}>
64 abstract generateSyndicationList (): void 60 abstract generateSyndicationList (): void
65 61
66 get user () { 62 get user () {
@@ -77,207 +73,96 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
77 .subscribe(() => this.calcPageSizes()) 73 .subscribe(() => this.calcPageSizes())
78 74
79 this.calcPageSizes() 75 this.calcPageSizes()
80 if (this.loadOnInit === true) this.loadMoreVideos(this.pagination.currentPage) 76 if (this.loadOnInit === true) this.loadMoreVideos()
81 } 77 }
82 78
83 ngOnDestroy () { 79 ngOnDestroy () {
84 if (this.resizeSubscription) this.resizeSubscription.unsubscribe() 80 if (this.resizeSubscription) this.resizeSubscription.unsubscribe()
85 } 81 }
86 82
87 pageByVideoId (index: number, page: Video[]) { 83 disableForReuse () {
88 // Video are unique in all pages 84 this.disabled = true
89 return page.length !== 0 ? page[0].id : 0
90 } 85 }
91 86
92 videoById (index: number, video: Video) { 87 enabledForReuse () {
93 return video.id 88 this.disabled = false
94 } 89 }
95 90
96 onNearOfTop () { 91 videoById (index: number, video: Video) {
97 this.previousPage() 92 return video.id
98 } 93 }
99 94
100 onNearOfBottom () { 95 onNearOfBottom () {
101 if (this.hasMoreVideos()) { 96 if (this.disabled) return
102 this.nextPage()
103 }
104 }
105
106 onPageChanged (page: number) {
107 this.pagination.currentPage = page
108 this.setNewRouteParams()
109 }
110 97
111 reloadVideos () { 98 // Last page
112 this.loadedPages = {} 99 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
113 this.loadMoreVideos(this.pagination.currentPage)
114 }
115 100
116 loadMoreVideos (page: number, loadOnTop = false) { 101 this.pagination.currentPage += 1
117 this.adjustVideoPageHeight()
118 102
119 const currentY = window.scrollY 103 this.setScrollRouteParams()
120 104
121 if (this.loadedPages[page] !== undefined) return 105 this.loadMoreVideos()
122 if (this.loadingPage[page] === true) return 106 }
123 107
124 this.loadingPage[page] = true 108 loadMoreVideos () {
125 const observable = this.getVideosObservable(page) 109 const observable = this.getVideosObservable(this.pagination.currentPage)
126 110
127 observable.subscribe( 111 observable.subscribe(
128 ({ videos, totalVideos }) => { 112 ({ videos, totalVideos }) => {
129 this.loadingPage[page] = false
130
131 if (this.firstLoadedPage === undefined || this.firstLoadedPage > page) this.firstLoadedPage = page
132
133 // Paging is too high, return to the first one
134 if (this.pagination.currentPage > 1 && totalVideos <= ((this.pagination.currentPage - 1) * this.pagination.itemsPerPage)) {
135 this.pagination.currentPage = 1
136 this.setNewRouteParams()
137 return this.reloadVideos()
138 }
139
140 this.loadedPages[page] = videos
141 this.buildVideoPages()
142 this.pagination.totalItems = totalVideos 113 this.pagination.totalItems = totalVideos
114 this.videos = this.videos.concat(videos)
143 115
144 // Initialize infinite scroller now we loaded the first page 116 this.onMoreVideos()
145 if (Object.keys(this.loadedPages).length === 1) {
146 // Wait elements creation
147 setTimeout(() => {
148 this.infiniteScroller.initialize()
149
150 // At our first load, we did not load the first page
151 // Load the previous page so the user can move on the top (and browser previous pages)
152 if (this.pagination.currentPage > 1) this.loadMoreVideos(this.pagination.currentPage - 1, true)
153 }, 500)
154 }
155
156 // Insert elements on the top but keep the scroll in the previous position
157 if (loadOnTop) setTimeout(() => { window.scrollTo(0, currentY + this.pageHeight) }, 0)
158 }, 117 },
159 error => {
160 this.loadingPage[page] = false
161 this.notifier.error(error.message)
162 }
163 )
164 }
165 118
166 toggleModerationDisplay () { 119 error => this.notifier.error(error.message)
167 throw new Error('toggleModerationDisplay is not implemented') 120 )
168 } 121 }
169 122
170 protected hasMoreVideos () { 123 reloadVideos () {
171 // No results 124 this.pagination.currentPage = 1
172 if (this.pagination.totalItems === 0) return false 125 this.videos = []
173 126 this.loadMoreVideos()
174 // Not loaded yet
175 if (!this.pagination.totalItems) return true
176
177 const maxPage = this.pagination.totalItems / this.pagination.itemsPerPage
178 return maxPage > this.maxPageLoaded()
179 } 127 }
180 128
181 protected previousPage () { 129 toggleModerationDisplay () {
182 const min = this.minPageLoaded() 130 throw new Error('toggleModerationDisplay is not implemented')
183
184 if (min > 1) {
185 this.loadMoreVideos(min - 1, true)
186 }
187 } 131 }
188 132
189 protected nextPage () { 133 removeVideoFromArray (video: Video) {
190 this.loadMoreVideos(this.maxPageLoaded() + 1) 134 this.videos = this.videos.filter(v => v.id !== video.id)
191 } 135 }
192 136
193 protected buildRouteParams () { 137 // On videos hook for children that want to do something
194 // There is always a sort and a current page 138 protected onMoreVideos () { /* empty */ }
195 const params = {
196 sort: this.sort,
197 page: this.pagination.currentPage
198 }
199
200 return Object.assign(params, this.otherRouteParams)
201 }
202 139
203 protected loadRouteParams (routeParams: { [ key: string ]: any }) { 140 protected loadRouteParams (routeParams: { [ key: string ]: any }) {
204 this.sort = routeParams['sort'] as VideoSortField || this.defaultSort 141 this.sort = routeParams[ 'sort' ] as VideoSortField || this.defaultSort
205 this.categoryOneOf = routeParams['categoryOneOf'] 142 this.categoryOneOf = routeParams[ 'categoryOneOf' ]
206 if (routeParams['page'] !== undefined) { 143 this.angularState = routeParams[ 'a-state' ]
207 this.pagination.currentPage = parseInt(routeParams['page'], 10)
208 } else {
209 this.pagination.currentPage = 1
210 }
211 }
212
213 protected setNewRouteParams () {
214 const paramsObject = this.buildRouteParams()
215
216 const queryParams = Object.keys(paramsObject)
217 .map(p => p + '=' + paramsObject[p])
218 .join('&')
219 this.location.replaceState(this.currentRoute, queryParams)
220 }
221
222 protected buildVideoPages () {
223 this.videoPages = Object.values(this.loadedPages)
224 }
225
226 protected adjustVideoPageHeight () {
227 const numberOfPagesLoaded = Object.keys(this.loadedPages).length
228 if (!numberOfPagesLoaded) return
229
230 this.pageHeight = this.videosElement.nativeElement.offsetHeight / numberOfPagesLoaded
231 }
232
233 protected buildVideoHeight () {
234 // Same ratios than base width/height
235 return this.videosElement.nativeElement.offsetWidth * (this.baseVideoHeight / this.baseVideoWidth)
236 }
237
238 private minPageLoaded () {
239 return Math.min(...Object.keys(this.loadedPages).map(e => parseInt(e, 10)))
240 }
241
242 private maxPageLoaded () {
243 return Math.max(...Object.keys(this.loadedPages).map(e => parseInt(e, 10)))
244 } 144 }
245 145
246 private calcPageSizes () { 146 private calcPageSizes () {
247 if (this.screenService.isInMobileView() || this.baseVideoWidth === -1) { 147 if (this.screenService.isInMobileView()) {
248 this.pagination.itemsPerPage = 5 148 this.pagination.itemsPerPage = 5
249
250 // Video takes all the width
251 this.videoWidth = -1
252 this.videoHeight = this.buildVideoHeight()
253 this.pageHeight = this.pagination.itemsPerPage * this.videoHeight
254 } else {
255 this.videoWidth = this.baseVideoWidth
256 this.videoHeight = this.baseVideoHeight
257
258 const videosWidth = this.videosElement.nativeElement.offsetWidth
259 this.pagination.itemsPerPage = Math.floor(videosWidth / this.videoWidth) * AbstractVideoList.LINES_PER_PAGE
260 this.pageHeight = this.videoHeight * AbstractVideoList.LINES_PER_PAGE
261 } 149 }
150 }
262 151
263 // Rebuild pages because maybe we modified the number of items per page 152 private setScrollRouteParams () {
264 const videos = [].concat(...this.videoPages) 153 // Already set
265 this.loadedPages = {} 154 if (this.angularState) return
266 155
267 let i = 1 156 this.angularState = 42
268 // Don't include the last page if it not complete
269 while (videos.length >= this.pagination.itemsPerPage && i < 10000) { // 10000 -> Hard limit in case of infinite loop
270 this.loadedPages[i] = videos.splice(0, this.pagination.itemsPerPage)
271 i++
272 }
273 157
274 // Re fetch the last page 158 const queryParams = {
275 if (videos.length !== 0) { 159 'a-state': this.angularState,
276 this.loadMoreVideos(i) 160 categoryOneOf: this.categoryOneOf
277 } else {
278 this.buildVideoPages()
279 } 161 }
280 162
281 console.log('Rebuilt pages with %s elements per page.', this.pagination.itemsPerPage) 163 let path = this.router.url
164 if (!path || path === '/') path = this.serverService.getConfig().instance.defaultClientRoute
165
166 this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' })
282 } 167 }
283} 168}
diff --git a/client/src/app/shared/video/infinite-scroller.directive.ts b/client/src/app/shared/video/infinite-scroller.directive.ts
index a02e9444a..5f8a1dd6e 100644
--- a/client/src/app/shared/video/infinite-scroller.directive.ts
+++ b/client/src/app/shared/video/infinite-scroller.directive.ts
@@ -1,30 +1,23 @@
1import { distinct, distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' 1import { distinct, distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
2import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' 2import { Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
3import { fromEvent, Subscription } from 'rxjs' 3import { fromEvent, Subscription } from 'rxjs'
4 4
5@Directive({ 5@Directive({
6 selector: '[myInfiniteScroller]' 6 selector: '[myInfiniteScroller]'
7}) 7})
8export class InfiniteScrollerDirective implements OnInit, OnDestroy { 8export class InfiniteScrollerDirective implements OnInit, OnDestroy {
9 @Input() containerHeight: number
10 @Input() pageHeight: number
11 @Input() firstLoadedPage = 1
12 @Input() percentLimit = 70 9 @Input() percentLimit = 70
13 @Input() autoInit = false 10 @Input() autoInit = false
11 @Input() onItself = false
14 12
15 @Output() nearOfBottom = new EventEmitter<void>() 13 @Output() nearOfBottom = new EventEmitter<void>()
16 @Output() nearOfTop = new EventEmitter<void>()
17 @Output() pageChanged = new EventEmitter<number>()
18 14
19 private decimalLimit = 0 15 private decimalLimit = 0
20 private lastCurrentBottom = -1 16 private lastCurrentBottom = -1
21 private lastCurrentTop = 0
22 private scrollDownSub: Subscription 17 private scrollDownSub: Subscription
23 private scrollUpSub: Subscription 18 private container: HTMLElement
24 private pageChangeSub: Subscription
25 private middleScreen: number
26 19
27 constructor () { 20 constructor (private el: ElementRef) {
28 this.decimalLimit = this.percentLimit / 100 21 this.decimalLimit = this.percentLimit / 100
29 } 22 }
30 23
@@ -34,21 +27,21 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy {
34 27
35 ngOnDestroy () { 28 ngOnDestroy () {
36 if (this.scrollDownSub) this.scrollDownSub.unsubscribe() 29 if (this.scrollDownSub) this.scrollDownSub.unsubscribe()
37 if (this.scrollUpSub) this.scrollUpSub.unsubscribe()
38 if (this.pageChangeSub) this.pageChangeSub.unsubscribe()
39 } 30 }
40 31
41 initialize () { 32 initialize () {
42 this.middleScreen = window.innerHeight / 2 33 if (this.onItself) {
34 this.container = this.el.nativeElement
35 }
43 36
44 // Emit the last value 37 // Emit the last value
45 const throttleOptions = { leading: true, trailing: true } 38 const throttleOptions = { leading: true, trailing: true }
46 39
47 const scrollObservable = fromEvent(window, 'scroll') 40 const scrollObservable = fromEvent(this.container || window, 'scroll')
48 .pipe( 41 .pipe(
49 startWith(null), 42 startWith(null),
50 throttleTime(200, undefined, throttleOptions), 43 throttleTime(200, undefined, throttleOptions),
51 map(() => ({ current: window.scrollY, maximumScroll: document.body.clientHeight - window.innerHeight })), 44 map(() => this.getScrollInfo()),
52 distinctUntilChanged((o1, o2) => o1.current === o2.current), 45 distinctUntilChanged((o1, o2) => o1.current === o2.current),
53 share() 46 share()
54 ) 47 )
@@ -66,39 +59,13 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy {
66 filter(({ current, maximumScroll }) => maximumScroll <= 0 || (current / maximumScroll) > this.decimalLimit) 59 filter(({ current, maximumScroll }) => maximumScroll <= 0 || (current / maximumScroll) > this.decimalLimit)
67 ) 60 )
68 .subscribe(() => this.nearOfBottom.emit()) 61 .subscribe(() => this.nearOfBottom.emit())
69
70 // Scroll up
71 this.scrollUpSub = scrollObservable
72 .pipe(
73 // Check we scroll up
74 filter(({ current }) => {
75 const res = this.lastCurrentTop > current
76
77 this.lastCurrentTop = current
78 return res
79 }),
80 filter(({ current, maximumScroll }) => {
81 return current !== 0 && (1 - (current / maximumScroll)) > this.decimalLimit
82 })
83 )
84 .subscribe(() => this.nearOfTop.emit())
85
86 // Page change
87 this.pageChangeSub = scrollObservable
88 .pipe(
89 distinct(),
90 map(({ current }) => this.calculateCurrentPage(current)),
91 distinctUntilChanged()
92 )
93 .subscribe(res => this.pageChanged.emit(res))
94 } 62 }
95 63
96 private calculateCurrentPage (current: number) { 64 private getScrollInfo () {
97 const scrollY = current + this.middleScreen 65 if (this.container) {
98 66 return { current: this.container.scrollTop, maximumScroll: this.container.scrollHeight }
99 const page = Math.max(1, Math.ceil(scrollY / this.pageHeight)) 67 }
100 68
101 // Offset page 69 return { current: window.scrollY, maximumScroll: document.body.clientHeight - window.innerHeight }
102 return page + (this.firstLoadedPage - 1)
103 } 70 }
104} 71}
diff --git a/client/src/app/videos/+video-watch/modal/video-blacklist.component.html b/client/src/app/shared/video/modals/video-blacklist.component.html
index 1a87bdcd4..1a87bdcd4 100644
--- a/client/src/app/videos/+video-watch/modal/video-blacklist.component.html
+++ b/client/src/app/shared/video/modals/video-blacklist.component.html
diff --git a/client/src/app/videos/+video-watch/modal/video-blacklist.component.scss b/client/src/app/shared/video/modals/video-blacklist.component.scss
index afcdb9a16..afcdb9a16 100644
--- a/client/src/app/videos/+video-watch/modal/video-blacklist.component.scss
+++ b/client/src/app/shared/video/modals/video-blacklist.component.scss
diff --git a/client/src/app/videos/+video-watch/modal/video-blacklist.component.ts b/client/src/app/shared/video/modals/video-blacklist.component.ts
index 50a7cadd1..4e4e8dc50 100644
--- a/client/src/app/videos/+video-watch/modal/video-blacklist.component.ts
+++ b/client/src/app/shared/video/modals/video-blacklist.component.ts
@@ -1,11 +1,12 @@
1import { Component, Input, OnInit, 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 { FormReactive, VideoBlacklistService, VideoBlacklistValidatorsService } from '../../../shared/index' 3import { VideoBlacklistService } from '../../../shared/video-blacklist'
4import { VideoDetails } from '../../../shared/video/video-details.model' 4import { VideoDetails } from '../../../shared/video/video-details.model'
5import { I18n } from '@ngx-translate/i18n-polyfill' 5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
9import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms'
9 10
10@Component({ 11@Component({
11 selector: 'my-video-blacklist', 12 selector: 'my-video-blacklist',
@@ -17,6 +18,8 @@ export class VideoBlacklistComponent extends FormReactive implements OnInit {
17 18
18 @ViewChild('modal') modal: NgbModal 19 @ViewChild('modal') modal: NgbModal
19 20
21 @Output() videoBlacklisted = new EventEmitter()
22
20 error: string = null 23 error: string = null
21 24
22 private openedModal: NgbModalRef 25 private openedModal: NgbModalRef
@@ -60,7 +63,11 @@ export class VideoBlacklistComponent extends FormReactive implements OnInit {
60 () => { 63 () => {
61 this.notifier.success(this.i18n('Video blacklisted.')) 64 this.notifier.success(this.i18n('Video blacklisted.'))
62 this.hide() 65 this.hide()
63 this.redirectService.redirectToHomepage() 66
67 this.video.blacklisted = true
68 this.video.blacklistedReason = reason
69
70 this.videoBlacklisted.emit()
64 }, 71 },
65 72
66 err => this.notifier.error(err.message) 73 err => this.notifier.error(err.message)
diff --git a/client/src/app/videos/+video-watch/modal/video-download.component.html b/client/src/app/shared/video/modals/video-download.component.html
index 2bb5d6d37..dd01c1388 100644
--- a/client/src/app/videos/+video-watch/modal/video-download.component.html
+++ b/client/src/app/shared/video/modals/video-download.component.html
@@ -9,7 +9,7 @@
9 <div class="input-group input-group-sm"> 9 <div class="input-group input-group-sm">
10 <div class="input-group-prepend peertube-select-container"> 10 <div class="input-group-prepend peertube-select-container">
11 <select [(ngModel)]="resolutionId"> 11 <select [(ngModel)]="resolutionId">
12 <option *ngFor="let file of video.files" [value]="file.resolution.id">{{ file.resolution.label }}</option> 12 <option *ngFor="let file of video?.files" [value]="file.resolution.id">{{ file.resolution.label }}</option>
13 </select> 13 </select>
14 </div> 14 </div>
15 <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" /> 15 <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" />
diff --git a/client/src/app/videos/+video-watch/modal/video-download.component.scss b/client/src/app/shared/video/modals/video-download.component.scss
index 3e826c3b6..3e826c3b6 100644
--- a/client/src/app/videos/+video-watch/modal/video-download.component.scss
+++ b/client/src/app/shared/video/modals/video-download.component.scss
diff --git a/client/src/app/videos/+video-watch/modal/video-download.component.ts b/client/src/app/shared/video/modals/video-download.component.ts
index 834385771..d6d10d29e 100644
--- a/client/src/app/videos/+video-watch/modal/video-download.component.ts
+++ b/client/src/app/shared/video/modals/video-download.component.ts
@@ -1,4 +1,4 @@
1import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core' 1import { Component, ElementRef, ViewChild } from '@angular/core'
2import { VideoDetails } from '../../../shared/video/video-details.model' 2import { VideoDetails } from '../../../shared/video/video-details.model'
3import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 3import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
@@ -9,26 +9,32 @@ import { Notifier } from '@app/core'
9 templateUrl: './video-download.component.html', 9 templateUrl: './video-download.component.html',
10 styleUrls: [ './video-download.component.scss' ] 10 styleUrls: [ './video-download.component.scss' ]
11}) 11})
12export class VideoDownloadComponent implements OnInit { 12export class VideoDownloadComponent {
13 @Input() video: VideoDetails = null
14
15 @ViewChild('modal') modal: ElementRef 13 @ViewChild('modal') modal: ElementRef
16 14
17 downloadType: 'direct' | 'torrent' | 'magnet' = 'torrent' 15 downloadType: 'direct' | 'torrent' | 'magnet' = 'torrent'
18 resolutionId: number | string = -1 16 resolutionId: number | string = -1
19 17
18 video: VideoDetails
19
20 constructor ( 20 constructor (
21 private notifier: Notifier, 21 private notifier: Notifier,
22 private modalService: NgbModal, 22 private modalService: NgbModal,
23 private i18n: I18n 23 private i18n: I18n
24 ) { } 24 ) { }
25 25
26 ngOnInit () { 26 show (video: VideoDetails) {
27 this.video = video
28
29 const m = this.modalService.open(this.modal)
30 m.result.then(() => this.onClose())
31 .catch(() => this.onClose())
32
27 this.resolutionId = this.video.files[0].resolution.id 33 this.resolutionId = this.video.files[0].resolution.id
28 } 34 }
29 35
30 show () { 36 onClose () {
31 this.modalService.open(this.modal) 37 this.video = undefined
32 } 38 }
33 39
34 download () { 40 download () {
@@ -45,21 +51,16 @@ export class VideoDownloadComponent implements OnInit {
45 return 51 return
46 } 52 }
47 53
48 const link = (() => { 54 switch (this.downloadType) {
49 switch (this.downloadType) { 55 case 'direct':
50 case 'direct': { 56 return file.fileDownloadUrl
51 return file.fileDownloadUrl 57
52 } 58 case 'torrent':
53 case 'torrent': { 59 return file.torrentDownloadUrl
54 return file.torrentDownloadUrl
55 }
56 case 'magnet': {
57 return file.magnetUri
58 }
59 }
60 })()
61 60
62 return link 61 case 'magnet':
62 return file.magnetUri
63 }
63 } 64 }
64 65
65 activateCopiedMessage () { 66 activateCopiedMessage () {
diff --git a/client/src/app/videos/+video-watch/modal/video-report.component.html b/client/src/app/shared/video/modals/video-report.component.html
index b9434da26..b9434da26 100644
--- a/client/src/app/videos/+video-watch/modal/video-report.component.html
+++ b/client/src/app/shared/video/modals/video-report.component.html
diff --git a/client/src/app/videos/+video-watch/modal/video-report.component.scss b/client/src/app/shared/video/modals/video-report.component.scss
index 4713660a2..4713660a2 100644
--- a/client/src/app/videos/+video-watch/modal/video-report.component.scss
+++ b/client/src/app/shared/video/modals/video-report.component.scss
diff --git a/client/src/app/videos/+video-watch/modal/video-report.component.ts b/client/src/app/shared/video/modals/video-report.component.ts
index 911f3b447..725dd020f 100644
--- a/client/src/app/videos/+video-watch/modal/video-report.component.ts
+++ b/client/src/app/shared/video/modals/video-report.component.ts
@@ -1,12 +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, VideoAbuseService } from '../../../shared/index' 3import { FormReactive } from '../../../shared/forms'
4import { VideoDetails } from '../../../shared/video/video-details.model' 4import { VideoDetails } from '../../../shared/video/video-details.model'
5import { I18n } from '@ngx-translate/i18n-polyfill' 5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' 7import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service'
8import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 8import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
9import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 9import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
10import { VideoAbuseService } from '@app/shared/video-abuse'
10 11
11@Component({ 12@Component({
12 selector: 'my-video-report', 13 selector: 'my-video-report',
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.html b/client/src/app/shared/video/video-actions-dropdown.component.html
new file mode 100644
index 000000000..ec03fa55d
--- /dev/null
+++ b/client/src/app/shared/video/video-actions-dropdown.component.html
@@ -0,0 +1,21 @@
1<ng-container *ngIf="videoActions.length !== 0">
2
3 <div class="playlist-dropdown" ngbDropdown #playlistDropdown="ngbDropdown" role="button" autoClose="outside" [placement]="getPlaylistDropdownPlacement()"
4 *ngIf="isUserLoggedIn() && displayOptions.playlist" (openChange)="playlistAdd.openChange($event)"
5 >
6 <span class="anchor" ngbDropdownAnchor></span>
7
8 <div ngbDropdownMenu>
9 <my-video-add-to-playlist #playlistAdd [video]="video" [lazyLoad]="true"></my-video-add-to-playlist>
10 </div>
11 </div>
12
13 <my-action-dropdown
14 [actions]="videoActions" [label]="label" [entry]="{ video: video }" (click)="loadDropdownInformation()"
15 [buttonSize]="buttonSize" [placement]="placement" [buttonDirection]="buttonDirection" [buttonStyled]="buttonStyled"
16 ></my-action-dropdown>
17
18 <my-video-download #videoDownloadModal></my-video-download>
19 <my-video-report #videoReportModal [video]="video"></my-video-report>
20 <my-video-blacklist #videoBlacklistModal [video]="video" (videoBlacklisted)="onVideoBlacklisted()"></my-video-blacklist>
21</ng-container>
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.scss b/client/src/app/shared/video/video-actions-dropdown.component.scss
new file mode 100644
index 000000000..7ffdce822
--- /dev/null
+++ b/client/src/app/shared/video/video-actions-dropdown.component.scss
@@ -0,0 +1,12 @@
1.playlist-dropdown {
2 position: absolute;
3
4 .anchor {
5 display: block;
6 opacity: 0;
7 }
8}
9
10/deep/ .icon-playlist-add {
11 left: 2px;
12}
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.ts b/client/src/app/shared/video/video-actions-dropdown.component.ts
new file mode 100644
index 000000000..ee2f44f9e
--- /dev/null
+++ b/client/src/app/shared/video/video-actions-dropdown.component.ts
@@ -0,0 +1,241 @@
1import { AfterViewInit, Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
2import { I18n } from '@ngx-translate/i18n-polyfill'
3import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component'
4import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core'
5import { BlocklistService } from '@app/shared/blocklist'
6import { Video } from '@app/shared/video/video.model'
7import { VideoService } from '@app/shared/video/video.service'
8import { VideoDetails } from '@app/shared/video/video-details.model'
9import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
10import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
11import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component'
12import { VideoReportComponent } from '@app/shared/video/modals/video-report.component'
13import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component'
14import { VideoBlacklistService } from '@app/shared/video-blacklist'
15import { ScreenService } from '@app/shared/misc/screen.service'
16
17export type VideoActionsDisplayType = {
18 playlist?: boolean
19 download?: boolean
20 update?: boolean
21 blacklist?: boolean
22 delete?: boolean
23 report?: boolean
24}
25
26@Component({
27 selector: 'my-video-actions-dropdown',
28 templateUrl: './video-actions-dropdown.component.html',
29 styleUrls: [ './video-actions-dropdown.component.scss' ]
30})
31export class VideoActionsDropdownComponent implements OnChanges {
32 @ViewChild('playlistDropdown') playlistDropdown: NgbDropdown
33 @ViewChild('playlistAdd') playlistAdd: VideoAddToPlaylistComponent
34
35 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
36 @ViewChild('videoReportModal') videoReportModal: VideoReportComponent
37 @ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent
38
39 @Input() video: Video | VideoDetails
40
41 @Input() displayOptions: VideoActionsDisplayType = {
42 playlist: false,
43 download: true,
44 update: true,
45 blacklist: true,
46 delete: true,
47 report: true
48 }
49 @Input() placement = 'left'
50
51 @Input() label: string
52
53 @Input() buttonStyled = false
54 @Input() buttonSize: DropdownButtonSize = 'normal'
55 @Input() buttonDirection: DropdownDirection = 'vertical'
56
57 @Output() videoRemoved = new EventEmitter()
58 @Output() videoUnblacklisted = new EventEmitter()
59 @Output() videoBlacklisted = new EventEmitter()
60
61 videoActions: DropdownAction<{ video: Video }>[][] = []
62
63 private loaded = false
64
65 constructor (
66 private authService: AuthService,
67 private notifier: Notifier,
68 private confirmService: ConfirmService,
69 private videoBlacklistService: VideoBlacklistService,
70 private serverService: ServerService,
71 private screenService: ScreenService,
72 private videoService: VideoService,
73 private blocklistService: BlocklistService,
74 private i18n: I18n
75 ) { }
76
77 get user () {
78 return this.authService.getUser()
79 }
80
81 ngOnChanges () {
82 this.buildActions()
83 }
84
85 isUserLoggedIn () {
86 return this.authService.isLoggedIn()
87 }
88
89 loadDropdownInformation () {
90 if (!this.isUserLoggedIn() || this.loaded === true) return
91
92 this.loaded = true
93
94 if (this.displayOptions.playlist) this.playlistAdd.load()
95 }
96
97 /* Show modals */
98
99 showDownloadModal () {
100 this.videoDownloadModal.show(this.video as VideoDetails)
101 }
102
103 showReportModal () {
104 this.videoReportModal.show()
105 }
106
107 showBlacklistModal () {
108 this.videoBlacklistModal.show()
109 }
110
111 /* Actions checker */
112
113 isVideoUpdatable () {
114 return this.video.isUpdatableBy(this.user)
115 }
116
117 isVideoRemovable () {
118 return this.video.isRemovableBy(this.user)
119 }
120
121 isVideoBlacklistable () {
122 return this.video.isBlackistableBy(this.user)
123 }
124
125 isVideoUnblacklistable () {
126 return this.video.isUnblacklistableBy(this.user)
127 }
128
129 isVideoDownloadable () {
130 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled
131 }
132
133 /* Action handlers */
134
135 async unblacklistVideo () {
136 const confirmMessage = this.i18n(
137 'Do you really want to remove this video from the blacklist? It will be available again in the videos list.'
138 )
139
140 const res = await this.confirmService.confirm(confirmMessage, this.i18n('Unblacklist'))
141 if (res === false) return
142
143 this.videoBlacklistService.removeVideoFromBlacklist(this.video.id).subscribe(
144 () => {
145 this.notifier.success(this.i18n('Video {{name}} removed from the blacklist.', { name: this.video.name }))
146
147 this.video.blacklisted = false
148 this.video.blacklistedReason = null
149
150 this.videoUnblacklisted.emit()
151 },
152
153 err => this.notifier.error(err.message)
154 )
155 }
156
157 async removeVideo () {
158 const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this video?'), this.i18n('Delete'))
159 if (res === false) return
160
161 this.videoService.removeVideo(this.video.id)
162 .subscribe(
163 () => {
164 this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: this.video.name }))
165
166 this.videoRemoved.emit()
167 },
168
169 error => this.notifier.error(error.message)
170 )
171 }
172
173 onVideoBlacklisted () {
174 this.videoBlacklisted.emit()
175 }
176
177 getPlaylistDropdownPlacement () {
178 if (this.screenService.isInSmallView()) {
179 return 'bottom-right'
180 }
181
182 return 'bottom-left bottom-right'
183 }
184
185 private buildActions () {
186 this.videoActions = []
187
188 if (this.authService.isLoggedIn()) {
189 this.videoActions.push([
190 {
191 label: this.i18n('Save to playlist'),
192 handler: () => this.playlistDropdown.toggle(),
193 isDisplayed: () => this.displayOptions.playlist,
194 iconName: 'playlist-add'
195 }
196 ])
197
198 this.videoActions.push([
199 {
200 label: this.i18n('Download'),
201 handler: () => this.showDownloadModal(),
202 isDisplayed: () => this.displayOptions.download && this.isVideoDownloadable(),
203 iconName: 'download'
204 },
205 {
206 label: this.i18n('Update'),
207 linkBuilder: ({ video }) => [ '/videos/update', video.uuid ],
208 iconName: 'edit',
209 isDisplayed: () => this.displayOptions.update && this.isVideoUpdatable()
210 },
211 {
212 label: this.i18n('Blacklist'),
213 handler: () => this.showBlacklistModal(),
214 iconName: 'no',
215 isDisplayed: () => this.displayOptions.blacklist && this.isVideoBlacklistable()
216 },
217 {
218 label: this.i18n('Unblacklist'),
219 handler: () => this.unblacklistVideo(),
220 iconName: 'undo',
221 isDisplayed: () => this.displayOptions.blacklist && this.isVideoUnblacklistable()
222 },
223 {
224 label: this.i18n('Delete'),
225 handler: () => this.removeVideo(),
226 isDisplayed: () => this.displayOptions.delete && this.isVideoRemovable(),
227 iconName: 'delete'
228 }
229 ])
230
231 this.videoActions.push([
232 {
233 label: this.i18n('Report'),
234 handler: () => this.showReportModal(),
235 isDisplayed: () => this.displayOptions.report,
236 iconName: 'alert'
237 }
238 ])
239 }
240 }
241}
diff --git a/client/src/app/shared/video/video-details.model.ts b/client/src/app/shared/video/video-details.model.ts
index fa4ca7f93..22f024656 100644
--- a/client/src/app/shared/video/video-details.model.ts
+++ b/client/src/app/shared/video/video-details.model.ts
@@ -3,6 +3,8 @@ import { AuthUser } from '../../core'
3import { Video } from '../../shared/video/video.model' 3import { Video } from '../../shared/video/video.model'
4import { Account } from '@app/shared/account/account.model' 4import { Account } from '@app/shared/account/account.model'
5import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 5import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
6import { VideoStreamingPlaylist } from '../../../../../shared/models/videos/video-streaming-playlist.model'
7import { VideoStreamingPlaylistType } from '../../../../../shared/models/videos/video-streaming-playlist.type'
6 8
7export class VideoDetails extends Video implements VideoDetailsServerModel { 9export class VideoDetails extends Video implements VideoDetailsServerModel {
8 descriptionPath: string 10 descriptionPath: string
@@ -12,6 +14,7 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
12 files: VideoFile[] 14 files: VideoFile[]
13 account: Account 15 account: Account
14 commentsEnabled: boolean 16 commentsEnabled: boolean
17 downloadEnabled: boolean
15 18
16 waitTranscoding: boolean 19 waitTranscoding: boolean
17 state: VideoConstant<VideoState> 20 state: VideoConstant<VideoState>
@@ -19,6 +22,10 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
19 likesPercent: number 22 likesPercent: number
20 dislikesPercent: number 23 dislikesPercent: number
21 24
25 trackerUrls: string[]
26
27 streamingPlaylists: VideoStreamingPlaylist[]
28
22 constructor (hash: VideoDetailsServerModel, translations = {}) { 29 constructor (hash: VideoDetailsServerModel, translations = {}) {
23 super(hash, translations) 30 super(hash, translations)
24 31
@@ -29,28 +36,24 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
29 this.tags = hash.tags 36 this.tags = hash.tags
30 this.support = hash.support 37 this.support = hash.support
31 this.commentsEnabled = hash.commentsEnabled 38 this.commentsEnabled = hash.commentsEnabled
39 this.downloadEnabled = hash.downloadEnabled
32 40
33 this.buildLikeAndDislikePercents() 41 this.trackerUrls = hash.trackerUrls
34 } 42 this.streamingPlaylists = hash.streamingPlaylists
35 43
36 isRemovableBy (user: AuthUser) { 44 this.buildLikeAndDislikePercents()
37 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.REMOVE_ANY_VIDEO))
38 }
39
40 isBlackistableBy (user: AuthUser) {
41 return this.blacklisted !== true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true
42 } 45 }
43 46
44 isUnblacklistableBy (user: AuthUser) { 47 buildLikeAndDislikePercents () {
45 return this.blacklisted === true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true 48 this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100
49 this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100
46 } 50 }
47 51
48 isUpdatableBy (user: AuthUser) { 52 getHlsPlaylist () {
49 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) 53 return this.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
50 } 54 }
51 55
52 buildLikeAndDislikePercents () { 56 hasHlsPlaylist () {
53 this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100 57 return !!this.getHlsPlaylist()
54 this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100
55 } 58 }
56} 59}
diff --git a/client/src/app/shared/video/video-edit.model.ts b/client/src/app/shared/video/video-edit.model.ts
index fc772a3cf..1f633d427 100644
--- a/client/src/app/shared/video/video-edit.model.ts
+++ b/client/src/app/shared/video/video-edit.model.ts
@@ -14,6 +14,7 @@ export class VideoEdit implements VideoUpdate {
14 tags: string[] 14 tags: string[]
15 nsfw: boolean 15 nsfw: boolean
16 commentsEnabled: boolean 16 commentsEnabled: boolean
17 downloadEnabled: boolean
17 waitTranscoding: boolean 18 waitTranscoding: boolean
18 channelId: number 19 channelId: number
19 privacy: VideoPrivacy 20 privacy: VideoPrivacy
@@ -25,8 +26,17 @@ export class VideoEdit implements VideoUpdate {
25 uuid?: string 26 uuid?: string
26 id?: number 27 id?: number
27 scheduleUpdate?: VideoScheduleUpdate 28 scheduleUpdate?: VideoScheduleUpdate
29 originallyPublishedAt?: Date | string
28 30
29 constructor (video?: Video & { tags: string[], commentsEnabled: boolean, support: string, thumbnailUrl: string, previewUrl: string }) { 31 constructor (
32 video?: Video & {
33 tags: string[],
34 commentsEnabled: boolean,
35 downloadEnabled: boolean,
36 support: string,
37 thumbnailUrl: string,
38 previewUrl: string
39 }) {
30 if (video) { 40 if (video) {
31 this.id = video.id 41 this.id = video.id
32 this.uuid = video.uuid 42 this.uuid = video.uuid
@@ -38,6 +48,7 @@ export class VideoEdit implements VideoUpdate {
38 this.tags = video.tags 48 this.tags = video.tags
39 this.nsfw = video.nsfw 49 this.nsfw = video.nsfw
40 this.commentsEnabled = video.commentsEnabled 50 this.commentsEnabled = video.commentsEnabled
51 this.downloadEnabled = video.downloadEnabled
41 this.waitTranscoding = video.waitTranscoding 52 this.waitTranscoding = video.waitTranscoding
42 this.channelId = video.channel.id 53 this.channelId = video.channel.id
43 this.privacy = video.privacy.id 54 this.privacy = video.privacy.id
@@ -46,6 +57,7 @@ export class VideoEdit implements VideoUpdate {
46 this.previewUrl = video.previewUrl 57 this.previewUrl = video.previewUrl
47 58
48 this.scheduleUpdate = video.scheduledUpdate 59 this.scheduleUpdate = video.scheduledUpdate
60 this.originallyPublishedAt = video.originallyPublishedAt ? new Date(video.originallyPublishedAt) : null
49 } 61 }
50 } 62 }
51 63
@@ -67,6 +79,12 @@ export class VideoEdit implements VideoUpdate {
67 } else { 79 } else {
68 this.scheduleUpdate = null 80 this.scheduleUpdate = null
69 } 81 }
82
83 // Convert originallyPublishedAt to string so that function objectToFormData() works correctly
84 if (this.originallyPublishedAt) {
85 const originallyPublishedAt = new Date(values['originallyPublishedAt'])
86 this.originallyPublishedAt = originallyPublishedAt.toISOString()
87 }
70 } 88 }
71 89
72 toFormPatch () { 90 toFormPatch () {
@@ -80,9 +98,11 @@ export class VideoEdit implements VideoUpdate {
80 tags: this.tags, 98 tags: this.tags,
81 nsfw: this.nsfw, 99 nsfw: this.nsfw,
82 commentsEnabled: this.commentsEnabled, 100 commentsEnabled: this.commentsEnabled,
101 downloadEnabled: this.downloadEnabled,
83 waitTranscoding: this.waitTranscoding, 102 waitTranscoding: this.waitTranscoding,
84 channelId: this.channelId, 103 channelId: this.channelId,
85 privacy: this.privacy 104 privacy: this.privacy,
105 originallyPublishedAt: this.originallyPublishedAt
86 } 106 }
87 107
88 // Special case if we scheduled an update 108 // Special case if we scheduled an update
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html
index 2c635fa2f..7af0f1113 100644
--- a/client/src/app/shared/video/video-miniature.component.html
+++ b/client/src/app/shared/video/video-miniature.component.html
@@ -1,25 +1,56 @@
1<div class="video-miniature"> 1<div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow }" (mouseenter)="loadActions()">
2 <my-video-thumbnail [video]="video" [nsfw]="isVideoBlur"></my-video-thumbnail> 2 <my-video-thumbnail [video]="video" [nsfw]="isVideoBlur"></my-video-thumbnail>
3 3
4 <div class="video-miniature-information"> 4 <div class="video-bottom">
5 <a 5 <div class="video-miniature-information">
6 tabindex="-1" 6 <a
7 class="video-miniature-name" 7 tabindex="-1"
8 [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" 8 class="video-miniature-name"
9 > 9 [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
10 <span *ngIf="isUnlistedVideo()" class="badge badge-warning" i18n>Unlisted</span> 10 >
11 <span *ngIf="isPrivateVideo()" class="badge badge-danger" i18n>Private</span> 11 <ng-container *ngIf="displayOptions.privacyLabel">
12 12 <span *ngIf="isUnlistedVideo()" class="badge badge-warning" i18n>Unlisted</span>
13 {{ video.name }} 13 <span *ngIf="isPrivateVideo()" class="badge badge-danger" i18n>Private</span>
14 </a> 14 </ng-container>
15 15
16 <span i18n class="video-miniature-created-at-views">{{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span> 16 {{ video.name }}
17 17 </a>
18 <a tabindex="-1" *ngIf="displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]"> 18
19 {{ video.byAccount }} 19 <span class="video-miniature-created-at-views">
20 </a> 20 <ng-container *ngIf="displayOptions.date">{{ video.publishedAt | myFromNow }}</ng-container>
21 <a tabindex="-1" *ngIf="displayOwnerVideoChannel()" class="video-miniature-channel" [routerLink]="[ '/video-channels', video.byVideoChannel ]"> 21 <ng-container *ngIf="displayOptions.date && displayOptions.views"> - </ng-container>
22 {{ video.byVideoChannel }} 22 <ng-container i18n *ngIf="displayOptions.views">{{ video.views | myNumberFormatter }} views</ng-container>
23 </a> 23 </span>
24
25 <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]">
26 {{ video.byAccount }}
27 </a>
28 <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerVideoChannel()" class="video-miniature-channel" [routerLink]="[ '/video-channels', video.byVideoChannel ]">
29 {{ video.byVideoChannel }}
30 </a>
31
32 <div class="video-info-privacy">
33 <ng-container *ngIf="displayOptions.privacyText">{{ video.privacy.label }}</ng-container>
34 <ng-container *ngIf="displayOptions.privacyText && displayOptions.state"> - </ng-container>
35 <ng-container *ngIf="displayOptions.state">{{ getStateLabel(video) }}</ng-container>
36 </div>
37
38 <div *ngIf="displayOptions.blacklistInfo && video.blacklisted" class="video-info-blacklisted">
39 <span class="blacklisted-label" i18n>Blacklisted</span>
40 <span class="blacklisted-reason" *ngIf="video.blacklistedReason">{{ video.blacklistedReason }}</span>
41 </div>
42
43 <div i18n *ngIf="displayOptions.nsfw && video.nsfw" class="video-info-nsfw">
44 Sensitive
45 </div>
46 </div>
47
48 <div class="video-actions">
49 <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown -->
50 <my-video-actions-dropdown
51 *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left"
52 (videoRemoved)="onVideoRemoved()" (videoBlacklisted)="onVideoBlacklisted()" (videoUnblacklisted)="onVideoUnblacklisted()"
53 ></my-video-actions-dropdown>
54 </div>
24 </div> 55 </div>
25</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 f44bdf9a9..d665ce021 100644
--- a/client/src/app/shared/video/video-miniature.component.scss
+++ b/client/src/app/shared/video/video-miniature.component.scss
@@ -1,59 +1,156 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3@import '_miniature';
4
5$more-button-width: 41px;
6$more-margin-right: 10px;
3 7
4.video-miniature { 8.video-miniature {
5 display: inline-block; 9 width: $video-miniature-width;
6 padding-right: 15px; 10 display: inline-flex;
11 flex-direction: column;
7 margin-bottom: 30px; 12 margin-bottom: 30px;
8 height: 175px; 13 height: 195px;
9 vertical-align: top; 14 vertical-align: top;
10 15
11 .video-miniature-information { 16 .video-bottom {
12 width: 200px; 17 display: flex;
13 margin-top: 2px; 18
14 line-height: normal; 19 .video-miniature-information {
20 width: $video-miniature-width - $more-button-width - $more-margin-right;
21 line-height: normal;
22
23 .video-miniature-name {
24 @include miniature-name;
25 }
26
27 .video-miniature-created-at-views {
28 display: block;
29 font-size: 13px;
30 }
31
32 .video-miniature-account,
33 .video-miniature-channel {
34 @include disable-default-a-behaviour;
35 @include ellipsis;
36
37 display: block;
38 font-size: 13px;
39 color: $grey-foreground-color;
40
41 &:hover {
42 color: $grey-foreground-hover-color;
43 }
44 }
45
46 .video-info-privacy,
47 .video-info-blacklisted .blacklisted-label,
48 .video-info-nsfw {
49 font-weight: $font-semibold;
50 }
51
52 .video-info-blacklisted {
53 color: red;
54
55 .blacklisted-reason::before {
56 content: ' - ';
57 }
58 }
59
60 .video-info-nsfw {
61 color: red;
62 }
63 }
15 64
16 .video-miniature-name { 65 .video-actions {
17 @include ellipsis-multiline( 66 margin-top: 3px;
18 $font-size: 1rem, 67 margin-right: $more-margin-right;
19 $line-height: 1, 68 width: $more-button-width;
20 $lines-to-show: 2 69 height: 30px;
21 );
22 transition: color 0.2s;
23 font-size: 16px;
24 font-weight: $font-semibold;
25 color: var(--mainForegroundColor);
26 margin-top: 5px;
27 margin-bottom: 5px;
28 70
29 &:hover { 71 /deep/ .dropdown-root:not(.show) {
30 text-decoration: none; 72 opacity: 0;
31 } 73 }
32 74
33 &.blur-filter { 75 /deep/ .playlist-dropdown.show + my-action-dropdown .dropdown-root {
34 filter: blur(3px); 76 opacity: 1;
35 padding-left: 4px;
36 } 77 }
37 } 78 }
38 79
39 .video-miniature-created-at-views { 80 &:hover .video-actions /deep/ .dropdown-root {
40 display: block; 81 opacity: 1;
41 font-size: 13px;
42 } 82 }
43 83
44 .video-miniature-account, 84 @media screen and (max-width: $small-view) {
45 .video-miniature-channel { 85 .video-miniature-information .video-miniature-name {
46 @include disable-default-a-behaviour; 86 margin-top: 0;
87 }
88
89 .video-actions {
90 margin: 0;
91 top: -3px;
47 92
48 display: block; 93 /deep/ .dropdown-root {
49 overflow: hidden; 94 opacity: 1 !important;
50 text-overflow: ellipsis; 95 }
51 white-space: nowrap; 96 }
52 font-size: 13px; 97 }
53 color: $grey-foreground-color; 98 }
99
100 &.display-as-row {
101 flex-direction: row;
102 margin-bottom: 0;
103 height: auto;
104 width: 100%;
105
106 my-video-thumbnail {
107 margin-right: 10px;
108 }
109
110 .video-bottom {
111 .video-miniature-information {
112 width: auto;
113 min-width: 500px;
114
115 .video-miniature-name {
116 @include ellipsis-multiline(1.3em, 2);
117
118 margin-top: 2px;
119 margin-bottom: 5px;
120 }
121
122 .video-miniature-created-at-views,
123 .video-miniature-account,
124 .video-miniature-channel {
125 font-size: 14px;
126 width: fit-content;
127 }
128
129 .video-info-privacy {
130 margin-top: 5px;
131 }
132
133 .video-info-blacklisted {
134 margin-top: 3px;
135 }
136 }
137
138 .video-actions {
139 margin: 0;
140 top: -3px;
141 }
142 }
143
144 @media screen and (max-width: $small-view) {
145 flex-direction: column;
146 height: auto;
147
148 my-video-thumbnail {
149 margin-right: 0;
150 }
54 151
55 &:hover { 152 .video-miniature-information {
56 color: $grey-foreground-hover-color; 153 min-width: initial;
57 } 154 }
58 } 155 }
59 } 156 }
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts
index 2f951a1f1..48475033c 100644
--- a/client/src/app/shared/video/video-miniature.component.ts
+++ b/client/src/app/shared/video/video-miniature.component.ts
@@ -1,10 +1,23 @@
1import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core' 1import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, LOCALE_ID, OnInit, Output } from '@angular/core'
2import { User } from '../users' 2import { User } from '../users'
3import { Video } from './video.model' 3import { Video } from './video.model'
4import { ServerService } from '@app/core' 4import { ServerService } from '@app/core'
5import { VideoPrivacy } from '../../../../../shared' 5import { VideoPrivacy, VideoState } from '../../../../../shared'
6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component'
8import { ScreenService } from '@app/shared/misc/screen.service'
6 9
7export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' 10export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto'
11export type MiniatureDisplayOptions = {
12 date?: boolean
13 views?: boolean
14 by?: boolean
15 privacyLabel?: boolean
16 privacyText?: boolean
17 state?: boolean
18 blacklistInfo?: boolean
19 nsfw?: boolean
20}
8 21
9@Component({ 22@Component({
10 selector: 'my-video-miniature', 23 selector: 'my-video-miniature',
@@ -15,31 +28,53 @@ export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto'
15export class VideoMiniatureComponent implements OnInit { 28export class VideoMiniatureComponent implements OnInit {
16 @Input() user: User 29 @Input() user: User
17 @Input() video: Video 30 @Input() video: Video
31
18 @Input() ownerDisplayType: OwnerDisplayType = 'account' 32 @Input() ownerDisplayType: OwnerDisplayType = 'account'
33 @Input() displayOptions: MiniatureDisplayOptions = {
34 date: true,
35 views: true,
36 by: true,
37 privacyLabel: false,
38 privacyText: false,
39 state: false,
40 blacklistInfo: false
41 }
42 @Input() displayAsRow = false
43 @Input() displayVideoActions = true
44
45 @Output() videoBlacklisted = new EventEmitter()
46 @Output() videoUnblacklisted = new EventEmitter()
47 @Output() videoRemoved = new EventEmitter()
48
49 videoActionsDisplayOptions: VideoActionsDisplayType = {
50 playlist: true,
51 download: false,
52 update: true,
53 blacklist: true,
54 delete: true,
55 report: true
56 }
57 showActions = false
19 58
20 private ownerDisplayTypeChosen: 'account' | 'videoChannel' 59 private ownerDisplayTypeChosen: 'account' | 'videoChannel'
21 60
22 constructor (private serverService: ServerService) { } 61 constructor (
62 private screenService: ScreenService,
63 private serverService: ServerService,
64 private i18n: I18n,
65 @Inject(LOCALE_ID) private localeId: string
66 ) { }
23 67
24 get isVideoBlur () { 68 get isVideoBlur () {
25 return this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig()) 69 return this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())
26 } 70 }
27 71
28 ngOnInit () { 72 ngOnInit () {
29 if (this.ownerDisplayType === 'account' || this.ownerDisplayType === 'videoChannel') { 73 this.setUpBy()
30 this.ownerDisplayTypeChosen = this.ownerDisplayType
31 return
32 }
33 74
34 // If the video channel name an UUID (not really displayable, we changed this behaviour in v1.0.0-beta.12) 75 // We rely on mouseenter to lazy load actions
35 // -> Use the account name 76 if (this.screenService.isInTouchScreen()) {
36 if ( 77 this.loadActions()
37 this.video.channel.name === `${this.video.account.name}_channel` ||
38 this.video.channel.name.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
39 ) {
40 this.ownerDisplayTypeChosen = 'account'
41 } else {
42 this.ownerDisplayTypeChosen = 'videoChannel'
43 } 78 }
44 } 79 }
45 80
@@ -58,4 +93,63 @@ export class VideoMiniatureComponent implements OnInit {
58 isPrivateVideo () { 93 isPrivateVideo () {
59 return this.video.privacy.id === VideoPrivacy.PRIVATE 94 return this.video.privacy.id === VideoPrivacy.PRIVATE
60 } 95 }
96
97 getStateLabel (video: Video) {
98 if (video.privacy.id !== VideoPrivacy.PRIVATE && video.state.id === VideoState.PUBLISHED) {
99 return this.i18n('Published')
100 }
101
102 if (video.scheduledUpdate) {
103 const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
104 return this.i18n('Publication scheduled on ') + updateAt
105 }
106
107 if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) {
108 return this.i18n('Waiting transcoding')
109 }
110
111 if (video.state.id === VideoState.TO_TRANSCODE) {
112 return this.i18n('To transcode')
113 }
114
115 if (video.state.id === VideoState.TO_IMPORT) {
116 return this.i18n('To import')
117 }
118
119 return ''
120 }
121
122 loadActions () {
123 if (this.displayVideoActions) this.showActions = true
124 }
125
126 onVideoBlacklisted () {
127 this.videoBlacklisted.emit()
128 }
129
130 onVideoUnblacklisted () {
131 this.videoUnblacklisted.emit()
132 }
133
134 onVideoRemoved () {
135 this.videoRemoved.emit()
136 }
137
138 private setUpBy () {
139 if (this.ownerDisplayType === 'account' || this.ownerDisplayType === 'videoChannel') {
140 this.ownerDisplayTypeChosen = this.ownerDisplayType
141 return
142 }
143
144 // If the video channel name an UUID (not really displayable, we changed this behaviour in v1.0.0-beta.12)
145 // -> Use the account name
146 if (
147 this.video.channel.name === `${this.video.account.name}_channel` ||
148 this.video.channel.name.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
149 ) {
150 this.ownerDisplayTypeChosen = 'account'
151 } else {
152 this.ownerDisplayTypeChosen = 'videoChannel'
153 }
154 }
61} 155}
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html
index d25666916..b302ebd0f 100644
--- a/client/src/app/shared/video/video-thumbnail.component.html
+++ b/client/src/app/shared/video/video-thumbnail.component.html
@@ -1,10 +1,14 @@
1<a 1<a
2 [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" 2 [routerLink]="getVideoRouterLink()" [queryParams]="queryParams" [attr.title]="video.name"
3 class="video-thumbnail" 3 class="video-thumbnail"
4> 4>
5 <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> 5 <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
6 6
7 <div class="video-thumbnail-overlay">{{ video.durationLabel }}</div> 7 <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div>
8
9 <div class="play-overlay">
10 <div class="icon"></div>
11 </div>
8 12
9 <div class="progress-bar" *ngIf="video.userHistory?.currentTime"> 13 <div class="progress-bar" *ngIf="video.userHistory?.currentTime">
10 <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div> 14 <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div>
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss
index 4772edaf0..469b659e9 100644
--- a/client/src/app/shared/video/video-thumbnail.component.scss
+++ b/client/src/app/shared/video/video-thumbnail.component.scss
@@ -1,39 +1,15 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3@import '_miniature';
3 4
4.video-thumbnail { 5.video-thumbnail {
5 display: inline-block; 6 @include miniature-thumbnail;
6 position: relative;
7 border-radius: 4px;
8 overflow: hidden;
9 width: $video-thumbnail-width;
10 height: $video-thumbnail-height;
11 background-color: #ececec;
12
13 &:hover {
14 text-decoration: none !important;
15 }
16
17 @include disable-outline;
18 &.focus-visible {
19 box-shadow: 0 0 0 2px var(--mainColor);
20 }
21
22 img {
23 width: $video-thumbnail-width;
24 height: $video-thumbnail-height;
25
26 &.blur-filter {
27 filter: blur(5px);
28 transform : scale(1.03);
29 }
30 }
31 7
32 .progress-bar { 8 .progress-bar {
33 height: 3px; 9 height: 3px;
34 width: 100%; 10 width: 100%;
35 position: relative; 11 position: absolute;
36 top: -3px; 12 bottom: 0;
37 background-color: rgba(0, 0, 0, 0.20); 13 background-color: rgba(0, 0, 0, 0.20);
38 14
39 div { 15 div {
@@ -42,16 +18,15 @@
42 } 18 }
43 } 19 }
44 20
45 .video-thumbnail-overlay { 21 .video-thumbnail-duration-overlay {
22 @include static-thumbnail-overlay;
23
46 position: absolute; 24 position: absolute;
47 right: 5px; 25 right: 5px;
48 bottom: 5px; 26 bottom: 5px;
49 display: inline-block; 27 padding: 0 5px;
50 background-color: rgba(0, 0, 0, 0.7); 28 border-radius: 3px;
51 color: #fff;
52 font-size: 12px; 29 font-size: 12px;
53 font-weight: $font-bold; 30 font-weight: $font-bold;
54 border-radius: 3px;
55 padding: 0 5px;
56 } 31 }
57} 32}
diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts
index ca43700c7..fe65ade94 100644
--- a/client/src/app/shared/video/video-thumbnail.component.ts
+++ b/client/src/app/shared/video/video-thumbnail.component.ts
@@ -10,8 +10,11 @@ import { ScreenService } from '@app/shared/misc/screen.service'
10export class VideoThumbnailComponent { 10export class VideoThumbnailComponent {
11 @Input() video: Video 11 @Input() video: Video
12 @Input() nsfw = false 12 @Input() nsfw = false
13 @Input() routerLink: any[]
14 @Input() queryParams: any[]
13 15
14 constructor (private screenService: ScreenService) {} 16 constructor (private screenService: ScreenService) {
17 }
15 18
16 getImageUrl () { 19 getImageUrl () {
17 if (!this.video) return '' 20 if (!this.video) return ''
@@ -30,4 +33,10 @@ export class VideoThumbnailComponent {
30 33
31 return (currentTime / this.video.duration) * 100 34 return (currentTime / this.video.duration) * 100
32 } 35 }
36
37 getVideoRouterLink () {
38 if (this.routerLink) return this.routerLink
39
40 return [ '/videos/watch', this.video.uuid ]
41 }
33} 42}
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index 6ea83d13b..0cef3eb8f 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -1,11 +1,12 @@
1import { User } from '../' 1import { User } from '../'
2import { Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared' 2import { PlaylistElement, UserRight, Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared'
3import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 3import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
4import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model' 4import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model'
5import { durationToString, getAbsoluteAPIUrl } from '../misc/utils' 5import { durationToString, getAbsoluteAPIUrl } from '../misc/utils'
6import { peertubeTranslate, ServerConfig } from '../../../../../shared/models' 6import { peertubeTranslate, ServerConfig } from '../../../../../shared/models'
7import { Actor } from '@app/shared/actor/actor.model' 7import { Actor } from '@app/shared/actor/actor.model'
8import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model' 8import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model'
9import { AuthUser } from '@app/core'
9 10
10export class Video implements VideoServerModel { 11export class Video implements VideoServerModel {
11 byVideoChannel: string 12 byVideoChannel: string
@@ -17,6 +18,7 @@ export class Video implements VideoServerModel {
17 createdAt: Date 18 createdAt: Date
18 updatedAt: Date 19 updatedAt: Date
19 publishedAt: Date 20 publishedAt: Date
21 originallyPublishedAt: Date | string
20 category: VideoConstant<number> 22 category: VideoConstant<number>
21 licence: VideoConstant<number> 23 licence: VideoConstant<number>
22 language: VideoConstant<string> 24 language: VideoConstant<string>
@@ -46,6 +48,8 @@ export class Video implements VideoServerModel {
46 blacklisted?: boolean 48 blacklisted?: boolean
47 blacklistedReason?: string 49 blacklistedReason?: string
48 50
51 playlistElement?: PlaylistElement
52
49 account: { 53 account: {
50 id: number 54 id: number
51 uuid: string 55 uuid: string
@@ -116,12 +120,16 @@ export class Video implements VideoServerModel {
116 this.privacy.label = peertubeTranslate(this.privacy.label, translations) 120 this.privacy.label = peertubeTranslate(this.privacy.label, translations)
117 121
118 this.scheduledUpdate = hash.scheduledUpdate 122 this.scheduledUpdate = hash.scheduledUpdate
123 this.originallyPublishedAt = hash.originallyPublishedAt ? new Date(hash.originallyPublishedAt.toString()) : null
124
119 if (this.state) this.state.label = peertubeTranslate(this.state.label, translations) 125 if (this.state) this.state.label = peertubeTranslate(this.state.label, translations)
120 126
121 this.blacklisted = hash.blacklisted 127 this.blacklisted = hash.blacklisted
122 this.blacklistedReason = hash.blacklistedReason 128 this.blacklistedReason = hash.blacklistedReason
123 129
124 this.userHistory = hash.userHistory 130 this.userHistory = hash.userHistory
131
132 this.playlistElement = hash.playlistElement
125 } 133 }
126 134
127 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { 135 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
@@ -134,4 +142,20 @@ export class Video implements VideoServerModel {
134 // Return default instance config 142 // Return default instance config
135 return serverConfig.instance.defaultNSFWPolicy !== 'display' 143 return serverConfig.instance.defaultNSFWPolicy !== 'display'
136 } 144 }
145
146 isRemovableBy (user: AuthUser) {
147 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.REMOVE_ANY_VIDEO))
148 }
149
150 isBlackistableBy (user: AuthUser) {
151 return this.blacklisted !== true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true
152 }
153
154 isUnblacklistableBy (user: AuthUser) {
155 return this.blacklisted === true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true
156 }
157
158 isUpdatableBy (user: AuthUser) {
159 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO))
160 }
137} 161}
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index 55844f988..ef489648c 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -31,6 +31,8 @@ import { ServerService } 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 { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
35import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
34 36
35export interface VideosProvider { 37export interface VideosProvider {
36 getVideos ( 38 getVideos (
@@ -81,6 +83,7 @@ export class VideoService implements VideosProvider {
81 const description = video.description || null 83 const description = video.description || null
82 const support = video.support || null 84 const support = video.support || null
83 const scheduleUpdate = video.scheduleUpdate || null 85 const scheduleUpdate = video.scheduleUpdate || null
86 const originallyPublishedAt = video.originallyPublishedAt || null
84 87
85 const body: VideoUpdate = { 88 const body: VideoUpdate = {
86 name: video.name, 89 name: video.name,
@@ -95,9 +98,11 @@ export class VideoService implements VideosProvider {
95 nsfw: video.nsfw, 98 nsfw: video.nsfw,
96 waitTranscoding: video.waitTranscoding, 99 waitTranscoding: video.waitTranscoding,
97 commentsEnabled: video.commentsEnabled, 100 commentsEnabled: video.commentsEnabled,
101 downloadEnabled: video.downloadEnabled,
98 thumbnailfile: video.thumbnailfile, 102 thumbnailfile: video.thumbnailfile,
99 previewfile: video.previewfile, 103 previewfile: video.previewfile,
100 scheduleUpdate 104 scheduleUpdate,
105 originallyPublishedAt
101 } 106 }
102 107
103 const data = objectToFormData(body) 108 const data = objectToFormData(body)
@@ -167,6 +172,23 @@ export class VideoService implements VideosProvider {
167 ) 172 )
168 } 173 }
169 174
175 getPlaylistVideos (
176 videoPlaylistId: number | string,
177 videoPagination: ComponentPagination
178 ): Observable<{ videos: Video[], totalVideos: number }> {
179 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
180
181 let params = new HttpParams()
182 params = this.restService.addRestGetParams(params, pagination)
183
184 return this.authHttp
185 .get<ResultList<Video>>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos', { params })
186 .pipe(
187 switchMap(res => this.extractVideos(res)),
188 catchError(err => this.restExtractor.handleError(err))
189 )
190 }
191
170 getUserSubscriptionVideos ( 192 getUserSubscriptionVideos (
171 videoPagination: ComponentPagination, 193 videoPagination: ComponentPagination,
172 sort: VideoSortField 194 sort: VideoSortField
diff --git a/client/src/app/shared/video/videos-selection.component.html b/client/src/app/shared/video/videos-selection.component.html
new file mode 100644
index 000000000..53809b6fd
--- /dev/null
+++ b/client/src/app/shared/video/videos-selection.component.html
@@ -0,0 +1,26 @@
1<div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div>
2
3<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" class="videos">
4 <div class="video" *ngFor="let video of videos; let i = index">
5 <div class="checkbox-container">
6 <my-peertube-checkbox [inputName]="'video-check-' + video.id" [(ngModel)]="_selection[video.id]"></my-peertube-checkbox>
7 </div>
8
9 <my-video-miniature [video]="video" [displayAsRow]="true" [displayOptions]="miniatureDisplayOptions" [displayVideoActions]="false"></my-video-miniature>
10
11 <!-- Display only once -->
12 <div class="action-selection-mode" *ngIf="isInSelectionMode() === true && i === 0">
13 <div class="action-selection-mode-child">
14 <span i18n class="action-button action-button-cancel-selection" (click)="abortSelectionMode()">
15 Cancel
16 </span>
17
18 <ng-container *ngTemplateOutlet="globalButtonsTemplate"></ng-container>
19 </div>
20 </div>
21
22 <ng-container *ngIf="isInSelectionMode() === false">
23 <ng-container *ngTemplateOutlet="rowButtonsTemplate; context: {$implicit: video}"></ng-container>
24 </ng-container>
25 </div>
26</div>
diff --git a/client/src/app/shared/video/videos-selection.component.scss b/client/src/app/shared/video/videos-selection.component.scss
new file mode 100644
index 000000000..d3cbabf23
--- /dev/null
+++ b/client/src/app/shared/video/videos-selection.component.scss
@@ -0,0 +1,57 @@
1@import '_variables';
2@import '_mixins';
3
4.action-selection-mode {
5 display: flex;
6 justify-content: flex-end;
7 flex-grow: 1;
8
9 .action-selection-mode-child {
10 position: fixed;
11
12 .action-button {
13 display: inline-block;
14 }
15
16 .action-button-cancel-selection {
17 @include peertube-button;
18 @include grey-button;
19
20 margin-right: 10px;
21 }
22 }
23}
24
25.video {
26 @include row-blocks;
27
28 &:first-child {
29 margin-top: 47px;
30 }
31
32 .checkbox-container {
33 display: flex;
34 align-items: center;
35 margin-right: 20px;
36 margin-left: 12px;
37 }
38
39 my-video-miniature {
40 flex-grow: 1;
41 }
42}
43
44@media screen and (max-width: $small-view) {
45 .video {
46 flex-direction: column;
47 height: auto;
48
49 .checkbox-container {
50 display: none;
51 }
52
53 my-button {
54 margin-top: 10px;
55 }
56 }
57}
diff --git a/client/src/app/shared/video/videos-selection.component.ts b/client/src/app/shared/video/videos-selection.component.ts
new file mode 100644
index 000000000..b6bedafd8
--- /dev/null
+++ b/client/src/app/shared/video/videos-selection.component.ts
@@ -0,0 +1,112 @@
1import {
2 AfterContentInit,
3 Component,
4 ContentChildren,
5 EventEmitter,
6 Input,
7 OnDestroy,
8 OnInit,
9 Output,
10 QueryList,
11 TemplateRef
12} from '@angular/core'
13import { ActivatedRoute, Router } from '@angular/router'
14import { AbstractVideoList } from '@app/shared/video/abstract-video-list'
15import { AuthService, Notifier, ServerService } from '@app/core'
16import { ScreenService } from '@app/shared/misc/screen.service'
17import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component'
18import { Observable } from 'rxjs'
19import { Video } from '@app/shared/video/video.model'
20import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
21import { VideoSortField } from '@app/shared/video/sort-field.type'
22
23export type SelectionType = { [ id: number ]: boolean }
24
25@Component({
26 selector: 'my-videos-selection',
27 templateUrl: './videos-selection.component.html',
28 styleUrls: [ './videos-selection.component.scss' ]
29})
30export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit {
31 @Input() titlePage: string
32 @Input() miniatureDisplayOptions: MiniatureDisplayOptions
33 @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<{ videos: Video[], totalVideos: number }>
34 @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective>
35
36 @Output() selectionChange = new EventEmitter<SelectionType>()
37 @Output() videosModelChange = new EventEmitter<Video[]>()
38
39 _selection: SelectionType = {}
40
41 rowButtonsTemplate: TemplateRef<any>
42 globalButtonsTemplate: TemplateRef<any>
43
44 constructor (
45 protected router: Router,
46 protected route: ActivatedRoute,
47 protected notifier: Notifier,
48 protected authService: AuthService,
49 protected screenService: ScreenService,
50 protected serverService: ServerService
51 ) {
52 super()
53 }
54
55 ngAfterContentInit () {
56 {
57 const t = this.templates.find(t => t.name === 'rowButtons')
58 if (t) this.rowButtonsTemplate = t.template
59 }
60
61 {
62 const t = this.templates.find(t => t.name === 'globalButtons')
63 if (t) this.globalButtonsTemplate = t.template
64 }
65 }
66
67 @Input() get selection () {
68 return this._selection
69 }
70
71 set selection (selection: SelectionType) {
72 this._selection = selection
73 this.selectionChange.emit(this._selection)
74 }
75
76 @Input() get videosModel () {
77 return this.videos
78 }
79
80 set videosModel (videos: Video[]) {
81 this.videos = videos
82 this.videosModelChange.emit(this.videos)
83 }
84
85 ngOnInit () {
86 super.ngOnInit()
87 }
88
89 ngOnDestroy () {
90 super.ngOnDestroy()
91 }
92
93 getVideosObservable (page: number) {
94 return this.getVideosObservableFunction(page, this.sort)
95 }
96
97 abortSelectionMode () {
98 this._selection = {}
99 }
100
101 isInSelectionMode () {
102 return Object.keys(this._selection).some(k => this._selection[ k ] === true)
103 }
104
105 generateSyndicationList () {
106 throw new Error('Method not implemented.')
107 }
108
109 protected onMoreVideos () {
110 this.videosModel = this.videos
111 }
112}
diff --git a/client/src/app/signup/signup-routing.module.ts b/client/src/app/signup/signup-routing.module.ts
index b7ac69b53..820d16d4d 100644
--- a/client/src/app/signup/signup-routing.module.ts
+++ b/client/src/app/signup/signup-routing.module.ts
@@ -1,9 +1,8 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router' 2import { RouterModule, Routes } from '@angular/router'
3
4import { MetaGuard } from '@ngx-meta/core' 3import { MetaGuard } from '@ngx-meta/core'
5
6import { SignupComponent } from './signup.component' 4import { SignupComponent } from './signup.component'
5import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service'
7 6
8const signupRoutes: Routes = [ 7const signupRoutes: Routes = [
9 { 8 {
@@ -14,6 +13,9 @@ const signupRoutes: Routes = [
14 meta: { 13 meta: {
15 title: 'Signup' 14 title: 'Signup'
16 } 15 }
16 },
17 resolve: {
18 serverConfigLoaded: ServerConfigResolver
17 } 19 }
18 } 20 }
19] 21]
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 092c0e862..99695204d 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
@@ -121,11 +121,6 @@
121 ></my-peertube-checkbox> 121 ></my-peertube-checkbox>
122 122
123 <my-peertube-checkbox 123 <my-peertube-checkbox
124 inputName="commentsEnabled" formControlName="commentsEnabled"
125 i18n-labelText labelText="Enable video comments"
126 ></my-peertube-checkbox>
127
128 <my-peertube-checkbox
129 *ngIf="waitTranscodingEnabled" 124 *ngIf="waitTranscodingEnabled"
130 inputName="waitTranscoding" formControlName="waitTranscoding" 125 inputName="waitTranscoding" formControlName="waitTranscoding"
131 i18n-labelText labelText="Wait transcoding before publishing the video" 126 i18n-labelText labelText="Wait transcoding before publishing the video"
@@ -190,31 +185,59 @@
190 185
191 <ngb-tab i18n-title title="Advanced settings"> 186 <ngb-tab i18n-title title="Advanced settings">
192 <ng-template ngbTabContent> 187 <ng-template ngbTabContent>
193 <div class="advanced-settings"> 188 <div class="row advanced-settings">
194 <div class="form-group"> 189 <div class="col-md-12 col-xl-8">
195 <my-video-image 190 <div class="form-group">
196 i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile" 191 <my-image-upload
197 previewWidth="200px" previewHeight="110px" 192 i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile"
198 ></my-video-image> 193 previewWidth="200px" previewHeight="110px"
199 </div> 194 ></my-image-upload>
195 </div>
196
197 <div class="form-group">
198 <my-image-upload
199 i18n-inputLabel inputLabel="Upload preview" inputName="previewfile" formControlName="previewfile"
200 previewWidth="360px" previewHeight="200px"
201 ></my-image-upload>
202 </div>
200 203
201 <div class="form-group"> 204 <div class="form-group">
202 <my-video-image 205 <label i18n for="support">Support</label>
203 i18n-inputLabel inputLabel="Upload preview" inputName="previewfile" formControlName="previewfile" 206 <my-help helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support you (membership platform...)."></my-help>
204 previewWidth="360px" previewHeight="200px" 207 <my-markdown-textarea
205 ></my-video-image> 208 id="support" formControlName="support" textareaWidth="500px" [previewColumn]="true" markdownType="enhanced"
209 [classes]="{ 'input-error': formErrors['support'] }"
210 ></my-markdown-textarea>
211 <div *ngIf="formErrors.support" class="form-error">
212 {{ formErrors.support }}
213 </div>
214 </div>
206 </div> 215 </div>
207 216
208 <div class="form-group"> 217 <div class="col-md-12 col-xl-4">
209 <label i18n for="support">Support</label> 218 <div class="form-group originally-published-at">
210 <my-help helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support you (membership platform...)."></my-help> 219 <label i18n for="originallyPublishedAt">Original publication date</label>
211 <my-markdown-textarea 220 <my-help i18n-preHtml preHtml="This is the date when the content was originally published (e.g. the release date for a film)"></my-help>
212 id="support" formControlName="support" textareaWidth="500px" [previewColumn]="true" markdownType="enhanced" 221 <p-calendar
213 [classes]="{ 'input-error': formErrors['support'] }" 222 id="originallyPublishedAt" formControlName="originallyPublishedAt" [dateFormat]="calendarDateFormat"
214 ></my-markdown-textarea> 223 [locale]="calendarLocale" [showTime]="true" [hideOnDateTimeSelect]="true" [monthNavigator]="true" [yearNavigator]="true" [yearRange]="myYearRange"
215 <div *ngIf="formErrors.support" class="form-error"> 224 >
216 {{ formErrors.support }} 225 </p-calendar>
226
227 <div *ngIf="formErrors.originallyPublishedAt" class="form-error">
228 {{ formErrors.originallyPublishedAt }}
229 </div>
217 </div> 230 </div>
231
232 <my-peertube-checkbox
233 inputName="commentsEnabled" formControlName="commentsEnabled"
234 i18n-labelText labelText="Enable video comments"
235 ></my-peertube-checkbox>
236
237 <my-peertube-checkbox
238 inputName="downloadEnabled" formControlName="downloadEnabled"
239 i18n-labelText labelText="Enable download"
240 ></my-peertube-checkbox>
218 </div> 241 </div>
219 </div> 242 </div>
220 </ng-template> 243 </ng-template>
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.ts b/client/src/app/videos/+video-edit/shared/video-edit.component.ts
index 85e015901..c80efd802 100644
--- a/client/src/app/videos/+video-edit/shared/video-edit.component.ts
+++ b/client/src/app/videos/+video-edit/shared/video-edit.component.ts
@@ -1,4 +1,4 @@
1import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core' 1import { Component, Input, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
2import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms' 2import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared' 4import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared'
@@ -26,7 +26,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
26 @Input() videoPrivacies: VideoConstant<VideoPrivacy>[] = [] 26 @Input() videoPrivacies: VideoConstant<VideoPrivacy>[] = []
27 @Input() userVideoChannels: { id: number, label: string, support: string }[] = [] 27 @Input() userVideoChannels: { id: number, label: string, support: string }[] = []
28 @Input() schedulePublicationPossible = true 28 @Input() schedulePublicationPossible = true
29 @Input() videoCaptions: VideoCaptionEdit[] = [] 29 @Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = []
30 @Input() waitTranscodingEnabled = true 30 @Input() waitTranscodingEnabled = true
31 31
32 @ViewChild('videoCaptionAddModal') videoCaptionAddModal: VideoCaptionAddModalComponent 32 @ViewChild('videoCaptionAddModal') videoCaptionAddModal: VideoCaptionAddModalComponent
@@ -45,6 +45,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
45 45
46 calendarLocale: any = {} 46 calendarLocale: any = {}
47 minScheduledDate = new Date() 47 minScheduledDate = new Date()
48 myYearRange = '1880:' + (new Date()).getFullYear()
48 49
49 calendarTimezone: string 50 calendarTimezone: string
50 calendarDateFormat: string 51 calendarDateFormat: string
@@ -61,7 +62,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
61 private router: Router, 62 private router: Router,
62 private notifier: Notifier, 63 private notifier: Notifier,
63 private serverService: ServerService, 64 private serverService: ServerService,
64 private i18nPrimengCalendarService: I18nPrimengCalendarService 65 private i18nPrimengCalendarService: I18nPrimengCalendarService,
66 private ngZone: NgZone
65 ) { 67 ) {
66 this.tagValidators = this.videoValidatorsService.VIDEO_TAGS.VALIDATORS 68 this.tagValidators = this.videoValidatorsService.VIDEO_TAGS.VALIDATORS
67 this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES 69 this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES
@@ -81,6 +83,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
81 const defaultValues: any = { 83 const defaultValues: any = {
82 nsfw: 'false', 84 nsfw: 'false',
83 commentsEnabled: 'true', 85 commentsEnabled: 'true',
86 downloadEnabled: 'true',
84 waitTranscoding: 'true', 87 waitTranscoding: 'true',
85 tags: [] 88 tags: []
86 } 89 }
@@ -90,6 +93,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
90 channelId: this.videoValidatorsService.VIDEO_CHANNEL, 93 channelId: this.videoValidatorsService.VIDEO_CHANNEL,
91 nsfw: null, 94 nsfw: null,
92 commentsEnabled: null, 95 commentsEnabled: null,
96 downloadEnabled: null,
93 waitTranscoding: null, 97 waitTranscoding: null,
94 category: this.videoValidatorsService.VIDEO_CATEGORY, 98 category: this.videoValidatorsService.VIDEO_CATEGORY,
95 licence: this.videoValidatorsService.VIDEO_LICENCE, 99 licence: this.videoValidatorsService.VIDEO_LICENCE,
@@ -99,7 +103,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
99 thumbnailfile: null, 103 thumbnailfile: null,
100 previewfile: null, 104 previewfile: null,
101 support: this.videoValidatorsService.VIDEO_SUPPORT, 105 support: this.videoValidatorsService.VIDEO_SUPPORT,
102 schedulePublicationAt: this.videoValidatorsService.VIDEO_SCHEDULE_PUBLICATION_AT 106 schedulePublicationAt: this.videoValidatorsService.VIDEO_SCHEDULE_PUBLICATION_AT,
107 originallyPublishedAt: this.videoValidatorsService.VIDEO_ORIGINALLY_PUBLISHED_AT
103 } 108 }
104 109
105 this.formValidatorService.updateForm( 110 this.formValidatorService.updateForm(
@@ -128,9 +133,11 @@ export class VideoEditComponent implements OnInit, OnDestroy {
128 this.videoLicences = this.serverService.getVideoLicences() 133 this.videoLicences = this.serverService.getVideoLicences()
129 this.videoLanguages = this.serverService.getVideoLanguages() 134 this.videoLanguages = this.serverService.getVideoLanguages()
130 135
131 this.schedulerInterval = setInterval(() => this.minScheduledDate = new Date(), 1000 * 60) // Update every minute
132
133 this.initialVideoCaptions = this.videoCaptions.map(c => c.language.id) 136 this.initialVideoCaptions = this.videoCaptions.map(c => c.language.id)
137
138 this.ngZone.runOutsideAngular(() => {
139 this.schedulerInterval = setInterval(() => this.minScheduledDate = new Date(), 1000 * 60) // Update every minute
140 })
134 } 141 }
135 142
136 ngOnDestroy () { 143 ngOnDestroy () {
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 f441d3fde..39b6daa93 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,6 @@ 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 { VideoImageComponent } from './video-image.component'
6import { CalendarModule } from 'primeng/components/calendar/calendar' 5import { CalendarModule } from 'primeng/components/calendar/calendar'
7import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' 6import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
8 7
@@ -16,7 +15,6 @@ import { VideoCaptionAddModalComponent } from './video-caption-add-modal.compone
16 15
17 declarations: [ 16 declarations: [
18 VideoEditComponent, 17 VideoEditComponent,
19 VideoImageComponent,
20 VideoCaptionAddModalComponent 18 VideoCaptionAddModalComponent
21 ], 19 ],
22 20
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 28eb143c9..537d7ffa2 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
@@ -58,7 +58,7 @@
58<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form"> 58<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form">
59 <my-video-edit 59 <my-video-edit
60 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false" 60 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
61 [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels" 61 [validationMessages]="validationMessages" [videoPrivacies]="explainedVideoPrivacies" [userVideoChannels]="userVideoChannels"
62 ></my-video-edit> 62 ></my-video-edit>
63 63
64 <div class="submit-container"> 64 <div class="submit-container">
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 307806bb9..d2e9f6cfe 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
@@ -79,6 +79,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca
79 privacy: this.firstStepPrivacyId, 79 privacy: this.firstStepPrivacyId,
80 waitTranscoding: false, 80 waitTranscoding: false,
81 commentsEnabled: true, 81 commentsEnabled: true,
82 downloadEnabled: true,
82 channelId: this.firstStepChannelId 83 channelId: this.firstStepChannelId
83 } 84 }
84 85
@@ -93,12 +94,13 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca
93 94
94 this.video = new VideoEdit(Object.assign(res.video, { 95 this.video = new VideoEdit(Object.assign(res.video, {
95 commentsEnabled: videoUpdate.commentsEnabled, 96 commentsEnabled: videoUpdate.commentsEnabled,
97 downloadEnabled: videoUpdate.downloadEnabled,
96 support: null, 98 support: null,
97 thumbnailUrl: null, 99 thumbnailUrl: null,
98 previewUrl: null 100 previewUrl: null
99 })) 101 }))
100 102
101 this.videoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies) 103 this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies)
102 104
103 this.hydrateFormFromVideo() 105 this.hydrateFormFromVideo()
104 }, 106 },
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 3550c3585..984b9d590 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
@@ -51,7 +51,7 @@
51<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form"> 51<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form">
52 <my-video-edit 52 <my-video-edit
53 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false" 53 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
54 [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels" 54 [validationMessages]="validationMessages" [videoPrivacies]="explainedVideoPrivacies" [userVideoChannels]="userVideoChannels"
55 ></my-video-edit> 55 ></my-video-edit>
56 56
57 <div class="submit-container"> 57 <div class="submit-container">
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 257c6e5db..2dffdbf0e 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
@@ -70,6 +70,7 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom
70 privacy: this.firstStepPrivacyId, 70 privacy: this.firstStepPrivacyId,
71 waitTranscoding: false, 71 waitTranscoding: false,
72 commentsEnabled: true, 72 commentsEnabled: true,
73 downloadEnabled: true,
73 channelId: this.firstStepChannelId 74 channelId: this.firstStepChannelId
74 } 75 }
75 76
@@ -84,12 +85,13 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom
84 85
85 this.video = new VideoEdit(Object.assign(res.video, { 86 this.video = new VideoEdit(Object.assign(res.video, {
86 commentsEnabled: videoUpdate.commentsEnabled, 87 commentsEnabled: videoUpdate.commentsEnabled,
88 downloadEnabled: videoUpdate.downloadEnabled,
87 support: null, 89 support: null,
88 thumbnailUrl: null, 90 thumbnailUrl: null,
89 previewUrl: null 91 previewUrl: null
90 })) 92 }))
91 93
92 this.videoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies) 94 this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies)
93 95
94 this.hydrateFormFromVideo() 96 this.hydrateFormFromVideo()
95 }, 97 },
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-send.ts b/client/src/app/videos/+video-edit/video-add-components/video-send.ts
index 580c123a0..8401caeec 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-send.ts
+++ b/client/src/app/videos/+video-edit/video-add-components/video-send.ts
@@ -14,6 +14,7 @@ import { CanComponentDeactivateResult } from '@app/shared/guards/can-deactivate-
14export abstract class VideoSend extends FormReactive implements OnInit { 14export abstract class VideoSend extends FormReactive implements OnInit {
15 userVideoChannels: { id: number, label: string, support: string }[] = [] 15 userVideoChannels: { id: number, label: string, support: string }[] = []
16 videoPrivacies: VideoConstant<VideoPrivacy>[] = [] 16 videoPrivacies: VideoConstant<VideoPrivacy>[] = []
17 explainedVideoPrivacies: VideoConstant<VideoPrivacy>[] = []
17 videoCaptions: VideoCaptionEdit[] = [] 18 videoCaptions: VideoCaptionEdit[] = []
18 19
19 firstStepPrivacyId = 0 20 firstStepPrivacyId = 0
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 b252cd60a..536769d2f 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
@@ -50,7 +50,7 @@
50<form [hidden]="!isUploadingVideo" novalidate [formGroup]="form"> 50<form [hidden]="!isUploadingVideo" novalidate [formGroup]="form">
51 <my-video-edit 51 <my-video-edit
52 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" 52 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
53 [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels" 53 [validationMessages]="validationMessages" [videoPrivacies]="explainedVideoPrivacies" [userVideoChannels]="userVideoChannels"
54 [waitTranscodingEnabled]="waitTranscodingEnabled" 54 [waitTranscodingEnabled]="waitTranscodingEnabled"
55 ></my-video-edit> 55 ></my-video-edit>
56 56
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 e4d54b654..d6d4bad21 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
@@ -163,9 +163,10 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
163 } 163 }
164 164
165 const privacy = this.firstStepPrivacyId.toString() 165 const privacy = this.firstStepPrivacyId.toString()
166 const nsfw = false 166 const nsfw = this.serverService.getConfig().instance.isNSFW
167 const waitTranscoding = true 167 const waitTranscoding = true
168 const commentsEnabled = true 168 const commentsEnabled = true
169 const downloadEnabled = true
169 const channelId = this.firstStepChannelId.toString() 170 const channelId = this.firstStepChannelId.toString()
170 171
171 const formData = new FormData() 172 const formData = new FormData()
@@ -174,6 +175,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
174 formData.append('privacy', VideoPrivacy.PRIVATE.toString()) 175 formData.append('privacy', VideoPrivacy.PRIVATE.toString())
175 formData.append('nsfw', '' + nsfw) 176 formData.append('nsfw', '' + nsfw)
176 formData.append('commentsEnabled', '' + commentsEnabled) 177 formData.append('commentsEnabled', '' + commentsEnabled)
178 formData.append('downloadEnabled', '' + downloadEnabled)
177 formData.append('waitTranscoding', '' + waitTranscoding) 179 formData.append('waitTranscoding', '' + waitTranscoding)
178 formData.append('channelId', '' + channelId) 180 formData.append('channelId', '' + channelId)
179 formData.append('videofile', videofile) 181 formData.append('videofile', videofile)
@@ -188,7 +190,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
188 channelId 190 channelId
189 }) 191 })
190 192
191 this.videoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies) 193 this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies)
192 194
193 this.videoUploadObservable = this.videoService.uploadVideo(formData).subscribe( 195 this.videoUploadObservable = this.videoService.uploadVideo(formData).subscribe(
194 event => { 196 event => {
diff --git a/client/src/app/videos/+video-edit/video-update.component.html b/client/src/app/videos/+video-edit/video-update.component.html
index 4992bb369..b5cab7ed5 100644
--- a/client/src/app/videos/+video-edit/video-update.component.html
+++ b/client/src/app/videos/+video-edit/video-update.component.html
@@ -7,7 +7,7 @@
7 7
8 <my-video-edit 8 <my-video-edit
9 [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible" 9 [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible"
10 [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels" 10 [validationMessages]="validationMessages" [videoPrivacies]="explainedVideoPrivacies" [userVideoChannels]="userVideoChannels"
11 [videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled" 11 [videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled"
12 ></my-video-edit> 12 ></my-video-edit>
13 13
diff --git a/client/src/app/videos/+video-edit/video-update.component.ts b/client/src/app/videos/+video-edit/video-update.component.ts
index 9e849014e..10f797d02 100644
--- a/client/src/app/videos/+video-edit/video-update.component.ts
+++ b/client/src/app/videos/+video-edit/video-update.component.ts
@@ -24,6 +24,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
24 24
25 isUpdatingVideo = false 25 isUpdatingVideo = false
26 videoPrivacies: VideoConstant<VideoPrivacy>[] = [] 26 videoPrivacies: VideoConstant<VideoPrivacy>[] = []
27 explainedVideoPrivacies: VideoConstant<VideoPrivacy>[] = []
27 userVideoChannels: { id: number, label: string, support: string }[] = [] 28 userVideoChannels: { id: number, label: string, support: string }[] = []
28 schedulePublicationPossible = false 29 schedulePublicationPossible = false
29 videoCaptions: VideoCaptionEdit[] = [] 30 videoCaptions: VideoCaptionEdit[] = []
@@ -65,7 +66,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
65 this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE 66 this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE
66 } 67 }
67 68
68 this.videoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies) 69 this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies)
69 70
70 const videoFiles = (video as VideoDetails).files 71 const videoFiles = (video as VideoDetails).files
71 if (videoFiles.length > 1) { // Already transcoded 72 if (videoFiles.length > 1) { // Already transcoded
diff --git a/client/src/app/videos/+video-edit/video-update.resolver.ts b/client/src/app/videos/+video-edit/video-update.resolver.ts
index 269fe3684..384458127 100644
--- a/client/src/app/videos/+video-edit/video-update.resolver.ts
+++ b/client/src/app/videos/+video-edit/video-update.resolver.ts
@@ -4,6 +4,7 @@ import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
4import { map, switchMap } from 'rxjs/operators' 4import { map, switchMap } from 'rxjs/operators'
5import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 5import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
6import { VideoCaptionService } from '@app/shared/video-caption' 6import { VideoCaptionService } from '@app/shared/video-caption'
7import { forkJoin } from 'rxjs'
7 8
8@Injectable() 9@Injectable()
9export class VideoUpdateResolver implements Resolve<any> { 10export class VideoUpdateResolver implements Resolve<any> {
@@ -11,35 +12,35 @@ export class VideoUpdateResolver implements Resolve<any> {
11 private videoService: VideoService, 12 private videoService: VideoService,
12 private videoChannelService: VideoChannelService, 13 private videoChannelService: VideoChannelService,
13 private videoCaptionService: VideoCaptionService 14 private videoCaptionService: VideoCaptionService
14 ) {} 15 ) {
16 }
15 17
16 resolve (route: ActivatedRouteSnapshot) { 18 resolve (route: ActivatedRouteSnapshot) {
17 const uuid: string = route.params[ 'uuid' ] 19 const uuid: string = route.params[ 'uuid' ]
18 20
19 return this.videoService.getVideo(uuid) 21 return this.videoService.getVideo(uuid)
20 .pipe( 22 .pipe(
21 switchMap(video => { 23 switchMap(video => {
22 return this.videoService 24 return forkJoin([
23 .loadCompleteDescription(video.descriptionPath) 25 this.videoService
24 .pipe(map(description => Object.assign(video, { description }))) 26 .loadCompleteDescription(video.descriptionPath)
25 }), 27 .pipe(map(description => Object.assign(video, { description }))),
26 switchMap(video => { 28
27 return this.videoChannelService 29 this.videoChannelService
28 .listAccountVideoChannels(video.account) 30 .listAccountVideoChannels(video.account)
29 .pipe( 31 .pipe(
30 map(result => result.data), 32 map(result => result.data),
31 map(videoChannels => videoChannels.map(c => ({ id: c.id, label: c.displayName, support: c.support }))), 33 map(videoChannels => videoChannels.map(c => ({ id: c.id, label: c.displayName, support: c.support })))
32 map(videoChannels => ({ video, videoChannels })) 34 ),
33 ) 35
34 }), 36 this.videoCaptionService
35 switchMap(({ video, videoChannels }) => { 37 .listCaptions(video.id)
36 return this.videoCaptionService 38 .pipe(
37 .listCaptions(video.id) 39 map(result => result.data)
38 .pipe( 40 )
39 map(result => result.data), 41 ])
40 map(videoCaptions => ({ video, videoChannels, videoCaptions })) 42 }),
41 ) 43 map(([ video, videoChannels, videoCaptions ]) => ({ video, videoChannels, videoCaptions }))
42 }) 44 )
43 )
44 } 45 }
45} 46}
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 aba7f9d1c..172eb0a39 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
@@ -85,8 +85,8 @@ export class VideoCommentComponent implements OnInit, OnChanges {
85 ) 85 )
86 } 86 }
87 87
88 private init () { 88 private async init () {
89 this.sanitizedCommentHTML = this.htmlRenderer.toSafeHtml(this.comment.text) 89 this.sanitizedCommentHTML = await this.htmlRenderer.toSafeHtml(this.comment.text)
90 90
91 this.newParentComments = this.parentComments.concat([ this.comment ]) 91 this.newParentComments = this.parentComments.concat([ this.comment ])
92 } 92 }
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 824fb24c3..3ed3ddcc7 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,6 +1,7 @@
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 } 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'
4 5
5export class VideoComment implements VideoCommentServerModel { 6export class VideoComment implements VideoCommentServerModel {
6 id: number 7 id: number
@@ -16,6 +17,8 @@ export class VideoComment implements VideoCommentServerModel {
16 by: string 17 by: string
17 accountAvatarUrl: string 18 accountAvatarUrl: string
18 19
20 isLocal: boolean
21
19 constructor (hash: VideoCommentServerModel) { 22 constructor (hash: VideoCommentServerModel) {
20 this.id = hash.id 23 this.id = hash.id
21 this.url = hash.url 24 this.url = hash.url
@@ -30,5 +33,9 @@ export class VideoComment implements VideoCommentServerModel {
30 33
31 this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host) 34 this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host)
32 this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account) 35 this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account)
36
37 const absoluteAPIUrl = getAbsoluteAPIUrl()
38 const thisHost = new URL(absoluteAPIUrl).host
39 this.isLocal = this.account.host.trim() === thisHost
33 } 40 }
34} 41}
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 44016d8ad..7b941454a 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
@@ -54,7 +54,7 @@
54 <ng-container i18n>View all {{ comment.totalReplies }} replies</ng-container> 54 <ng-container i18n>View all {{ comment.totalReplies }} replies</ng-container>
55 55
56 <span *ngIf="!threadLoading[comment.id]" class="glyphicon glyphicon-menu-down"></span> 56 <span *ngIf="!threadLoading[comment.id]" class="glyphicon glyphicon-menu-down"></span>
57 <my-loader class="comment-thread-loading" [loading]="threadLoading[comment.id]"></my-loader> 57 <my-small-loader class="comment-thread-loading" [loading]="threadLoading[comment.id]"></my-small-loader>
58 </div> 58 </div>
59 </div> 59 </div>
60 </div> 60 </div>
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 2616820d2..3acddbe6a 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
@@ -121,10 +121,17 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
121 121
122 async onWantedToDelete (commentToDelete: VideoComment) { 122 async onWantedToDelete (commentToDelete: VideoComment) {
123 let message = 'Do you really want to delete this comment?' 123 let message = 'Do you really want to delete this comment?'
124
124 if (commentToDelete.totalReplies !== 0) { 125 if (commentToDelete.totalReplies !== 0) {
125 message += this.i18n(' {{totalReplies}} replies will be deleted too.', { totalReplies: commentToDelete.totalReplies }) 126 message += this.i18n(' {{totalReplies}} replies will be deleted too.', { totalReplies: commentToDelete.totalReplies })
126 } 127 }
127 128
129 if (commentToDelete.isLocal) {
130 message += this.i18n(' The deletion will be sent to remote instances so they remove the comment too.')
131 } else {
132 message += this.i18n(' It is a remote comment, so the deletion will only be effective on your instance.')
133 }
134
128 const res = await this.confirmService.confirm(message, this.i18n('Delete')) 135 const res = await this.confirmService.confirm(message, this.i18n('Delete'))
129 if (res === false) return 136 if (res === false) return
130 137
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 9f3c37fe8..955b2b80c 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
@@ -6,11 +6,19 @@
6 6
7 <div class="modal-body"> 7 <div class="modal-body">
8 8
9 <div *ngIf="currentVideoTimestampString" class="start-at"> 9 <div class="start-at">
10 <my-peertube-checkbox 10 <my-peertube-checkbox
11 inputName="startAt" [(ngModel)]="startAtCheckbox" 11 inputName="startAt" [(ngModel)]="startAtCheckbox"
12 i18n-labelText [labelText]="getStartCheckboxLabel()" 12 i18n-labelText labelText="Start at"
13 ></my-peertube-checkbox> 13 ></my-peertube-checkbox>
14
15 <my-timestamp-input
16 [timestamp]="currentVideoTimestamp"
17 [maxTimestamp]="video.duration"
18 [disabled]="!startAtCheckbox"
19 [(ngModel)]="currentVideoTimestamp"
20 >
21 </my-timestamp-input>
14 </div> 22 </div>
15 23
16 <div class="form-group"> 24 <div class="form-group">
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 4937506b9..472a45920 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
@@ -13,4 +13,9 @@
13 display: flex; 13 display: flex;
14 justify-content: center; 14 justify-content: center;
15 margin-top: 10px; 15 margin-top: 10px;
16 align-items: center;
17
18 my-timestamp-input {
19 margin-left: 10px;
20 }
16} 21}
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 c6205e355..6565d7f88 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
@@ -16,10 +16,8 @@ export class VideoShareComponent {
16 16
17 @Input() video: VideoDetails = null 17 @Input() video: VideoDetails = null
18 18
19 currentVideoTimestamp: number
19 startAtCheckbox = false 20 startAtCheckbox = false
20 currentVideoTimestampString: string
21
22 private currentVideoTimestamp: number
23 21
24 constructor ( 22 constructor (
25 private modalService: NgbModal, 23 private modalService: NgbModal,
@@ -28,8 +26,7 @@ export class VideoShareComponent {
28 ) { } 26 ) { }
29 27
30 show (currentVideoTimestamp?: number) { 28 show (currentVideoTimestamp?: number) {
31 this.currentVideoTimestamp = Math.floor(currentVideoTimestamp) 29 this.currentVideoTimestamp = currentVideoTimestamp ? Math.floor(currentVideoTimestamp) : 0
32 this.currentVideoTimestampString = durationToString(this.currentVideoTimestamp)
33 30
34 this.modalService.open(this.modal) 31 this.modalService.open(this.modal)
35 } 32 }
@@ -52,10 +49,6 @@ export class VideoShareComponent {
52 this.notifier.success(this.i18n('Copied')) 49 this.notifier.success(this.i18n('Copied'))
53 } 50 }
54 51
55 getStartCheckboxLabel () {
56 return this.i18n('Start at {{timestamp}}', { timestamp: this.currentVideoTimestampString })
57 }
58
59 private getVideoTimestampIfEnabled () { 52 private getVideoTimestampIfEnabled () {
60 if (this.startAtCheckbox === true) return this.currentVideoTimestamp 53 if (this.startAtCheckbox === true) return this.currentVideoTimestamp
61 54
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 deb8fbc67..5e7afa012 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,9 @@ export class VideoSupportComponent {
21 ) { } 21 ) { }
22 22
23 show () { 23 show () {
24 this.videoHTMLSupport = this.markdownService.enhancedMarkdownToHTML(this.video.support)
25 this.modalService.open(this.modal) 24 this.modalService.open(this.modal)
25
26 this.markdownService.enhancedMarkdownToHTML(this.video.support)
27 .then(r => this.videoHTMLSupport = r)
26 } 28 }
27} 29}
diff --git a/client/src/app/videos/+video-watch/video-watch-playlist.component.html b/client/src/app/videos/+video-watch/video-watch-playlist.component.html
new file mode 100644
index 000000000..c168a3130
--- /dev/null
+++ b/client/src/app/videos/+video-watch/video-watch-playlist.component.html
@@ -0,0 +1,25 @@
1<div *ngIf="playlist && video" class="playlist" myInfiniteScroller [autoInit]="true" [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()">
2 <div class="playlist-info">
3 <div class="playlist-display-name">
4 {{ playlist.displayName }}
5
6 <span *ngIf="isUnlistedPlaylist()" class="badge badge-warning" i18n>Unlisted</span>
7 <span *ngIf="isPrivatePlaylist()" class="badge badge-danger" i18n>Private</span>
8 <span *ngIf="isPublicPlaylist()" class="badge badge-info" i18n>Public</span>
9 </div>
10
11 <div class="playlist-by-index">
12 <div class="playlist-by">{{ playlist.ownerBy }}</div>
13 <div class="playlist-index">
14 <span>{{ currentPlaylistPosition }}</span><span>{{ playlistPagination.totalItems }}</span>
15 </div>
16 </div>
17 </div>
18
19 <div *ngFor="let playlistVideo of playlistVideos">
20 <my-video-playlist-element-miniature
21 [video]="playlistVideo" [playlist]="playlist" [owned]="isPlaylistOwned()" (elementRemoved)="onElementRemoved($event)"
22 [playing]="currentPlaylistPosition === playlistVideo.playlistElement.position" [accountLink]="false" [position]="playlistVideo.playlistElement.position"
23 ></my-video-playlist-element-miniature>
24 </div>
25</div>
diff --git a/client/src/app/videos/+video-watch/video-watch-playlist.component.scss b/client/src/app/videos/+video-watch/video-watch-playlist.component.scss
new file mode 100644
index 000000000..5da55c2f8
--- /dev/null
+++ b/client/src/app/videos/+video-watch/video-watch-playlist.component.scss
@@ -0,0 +1,59 @@
1@import '_variables';
2@import '_mixins';
3@import '_bootstrap-variables';
4@import '_miniature';
5
6.playlist {
7 min-width: 200px;
8 max-width: 470px;
9 height: 66vh;
10 background-color: var(--mainBackgroundColor);
11 overflow-y: auto;
12 border-bottom: 1px solid $separator-border-color;
13
14 .playlist-info {
15 padding: 5px 30px;
16 background-color: #e4e4e4;
17
18 .playlist-display-name {
19 font-size: 18px;
20 font-weight: $font-semibold;
21 margin-bottom: 5px;
22 }
23
24 .playlist-by-index {
25 color: $grey-foreground-color;
26 display: flex;
27
28 .playlist-by {
29 margin-right: 5px;
30 }
31
32 .playlist-index span:first-child::after {
33 content: '/';
34 margin: 0 3px;
35 }
36 }
37 }
38
39 my-video-playlist-element-miniature {
40 /deep/ {
41 .video {
42 .position {
43 margin-right: 0;
44 }
45
46 .video-info {
47 .video-info-name {
48 font-size: 15px;
49 }
50 }
51 }
52
53 my-video-thumbnail {
54 @include thumbnail-size-component(90px, 50px);
55 }
56 }
57 }
58}
59
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
new file mode 100644
index 000000000..bccdaf7b2
--- /dev/null
+++ b/client/src/app/videos/+video-watch/video-watch-playlist.component.ts
@@ -0,0 +1,113 @@
1import { Component, Input } from '@angular/core'
2import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
3import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
4import { Video } from '@app/shared/video/video.model'
5import { VideoDetails, VideoPlaylistPrivacy } from '@shared/models'
6import { VideoService } from '@app/shared/video/video.service'
7import { Router } from '@angular/router'
8import { AuthService } from '@app/core'
9
10@Component({
11 selector: 'my-video-watch-playlist',
12 templateUrl: './video-watch-playlist.component.html',
13 styleUrls: [ './video-watch-playlist.component.scss' ]
14})
15export class VideoWatchPlaylistComponent {
16 @Input() video: VideoDetails
17 @Input() playlist: VideoPlaylist
18
19 playlistVideos: Video[] = []
20 playlistPagination: ComponentPagination = {
21 currentPage: 1,
22 itemsPerPage: 30,
23 totalItems: null
24 }
25
26 noPlaylistVideos = false
27 currentPlaylistPosition = 1
28
29 constructor (
30 private auth: AuthService,
31 private videoService: VideoService,
32 private router: Router
33 ) {}
34
35 onPlaylistVideosNearOfBottom () {
36 // Last page
37 if (this.playlistPagination.totalItems <= (this.playlistPagination.currentPage * this.playlistPagination.itemsPerPage)) return
38
39 this.playlistPagination.currentPage += 1
40 this.loadPlaylistElements(this.playlist,false)
41 }
42
43 onElementRemoved (video: Video) {
44 this.playlistVideos = this.playlistVideos.filter(v => v.id !== video.id)
45
46 this.playlistPagination.totalItems--
47 }
48
49 isPlaylistOwned () {
50 return this.playlist.isLocal === true &&
51 this.auth.isLoggedIn() &&
52 this.playlist.ownerAccount.name === this.auth.getUser().username
53 }
54
55 isUnlistedPlaylist () {
56 return this.playlist.privacy.id === VideoPlaylistPrivacy.UNLISTED
57 }
58
59 isPrivatePlaylist () {
60 return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE
61 }
62
63 isPublicPlaylist () {
64 return this.playlist.privacy.id === VideoPlaylistPrivacy.PUBLIC
65 }
66
67 loadPlaylistElements (playlist: VideoPlaylist, redirectToFirst = false) {
68 this.videoService.getPlaylistVideos(playlist.uuid, this.playlistPagination)
69 .subscribe(({ totalVideos, videos }) => {
70 this.playlistVideos = this.playlistVideos.concat(videos)
71 this.playlistPagination.totalItems = totalVideos
72
73 if (totalVideos === 0) {
74 this.noPlaylistVideos = true
75 return
76 }
77
78 this.updatePlaylistIndex(this.video)
79
80 if (redirectToFirst) {
81 const extras = {
82 queryParams: { videoId: this.playlistVideos[ 0 ].uuid },
83 replaceUrl: true
84 }
85 this.router.navigate([], extras)
86 }
87 })
88 }
89
90 updatePlaylistIndex (video: VideoDetails) {
91 if (this.playlistVideos.length === 0 || !video) return
92
93 for (const playlistVideo of this.playlistVideos) {
94 if (playlistVideo.id === video.id) {
95 this.currentPlaylistPosition = playlistVideo.playlistElement.position
96 return
97 }
98 }
99
100 // Load more videos to find our video
101 this.onPlaylistVideosNearOfBottom()
102 }
103
104 navigateToNextPlaylistVideo () {
105 if (this.currentPlaylistPosition < this.playlistPagination.totalItems) {
106 const next = this.playlistVideos.find(v => v.playlistElement.position === this.currentPlaylistPosition + 1)
107
108 const start = next.playlistElement.startTimestamp
109 const stop = next.playlistElement.stopTimestamp
110 this.router.navigate([],{ queryParams: { videoId: next.uuid, start, stop } })
111 }
112 }
113}
diff --git a/client/src/app/videos/+video-watch/video-watch-routing.module.ts b/client/src/app/videos/+video-watch/video-watch-routing.module.ts
index bdd4f945e..ce9250bdc 100644
--- a/client/src/app/videos/+video-watch/video-watch-routing.module.ts
+++ b/client/src/app/videos/+video-watch/video-watch-routing.module.ts
@@ -7,7 +7,16 @@ import { VideoWatchComponent } from './video-watch.component'
7 7
8const videoWatchRoutes: Routes = [ 8const videoWatchRoutes: Routes = [
9 { 9 {
10 path: '', 10 path: 'playlist/:playlistId',
11 component: VideoWatchComponent,
12 canActivate: [ MetaGuard ]
13 },
14 {
15 path: ':videoId/comments/:commentId',
16 redirectTo: ':videoId'
17 },
18 {
19 path: ':videoId',
11 component: VideoWatchComponent, 20 component: VideoWatchComponent,
12 canActivate: [ MetaGuard ] 21 canActivate: [ MetaGuard ]
13 } 22 }
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 709eb91a8..2e39b9c6b 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.html
+++ b/client/src/app/videos/+video-watch/video-watch.component.html
@@ -1,37 +1,53 @@
1<div class="root-row row"> 1<div class="root" [ngClass]="{ 'theater-enabled': theaterEnabled }">
2 <!-- We need the video container for videojs so we just hide it --> 2 <!-- We need the video container for videojs so we just hide it -->
3 <div id="video-element-wrapper"> 3 <div id="video-wrapper">
4 <div *ngIf="remoteServerDown" class="remote-server-down"> 4 <div *ngIf="remoteServerDown" class="remote-server-down">
5 Sorry, but this video is not available because the remote instance is not responding. 5 Sorry, but this video is not available because the remote instance is not responding.
6 <br /> 6 <br />
7 Please try again later. 7 Please try again later.
8 </div> 8 </div>
9 </div>
10 9
11 <div i18n class="alert alert-warning" *ngIf="isVideoToImport()"> 10 <div id="videojs-wrapper"></div>
12 The video is being imported, it will be available when the import is finished.
13 </div>
14 11
15 <div i18n class="alert alert-warning" *ngIf="isVideoToTranscode()"> 12 <my-video-watch-playlist
16 The video is being transcoded, it may not work properly. 13 #videoWatchPlaylist
14 [video]="video" [playlist]="playlist" class="playlist"
15 ></my-video-watch-playlist>
17 </div> 16 </div>
18 17
19 <div i18n class="alert alert-info" *ngIf="hasVideoScheduledPublication()"> 18 <div class="row">
20 This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}. 19 <div i18n class="col-md-12 alert alert-warning" *ngIf="isVideoToImport()">
21 </div> 20 The video is being imported, it will be available when the import is finished.
21 </div>
22
23 <div i18n class="col-md-12 alert alert-warning" *ngIf="isVideoToTranscode()">
24 The video is being transcoded, it may not work properly.
25 </div>
26
27 <div i18n class="col-md-12 alert alert-info" *ngIf="hasVideoScheduledPublication()">
28 This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}.
29 </div>
22 30
23 <div class="alert alert-danger" *ngIf="video?.blacklisted"> 31 <div class="col-md-12 alert alert-danger" *ngIf="video?.blacklisted">
24 <div class="blacklisted-label" i18n>This video is blacklisted.</div> 32 <div class="blacklisted-label" i18n>This video is blacklisted.</div>
25 {{ video.blacklistedReason }} 33 {{ video.blacklistedReason }}
34 </div>
26 </div> 35 </div>
27 36
28 <!-- Video information --> 37 <!-- Video information -->
29 <div *ngIf="video" class="margin-content video-bottom"> 38 <div *ngIf="video" class="margin-content video-bottom">
30 <div class="row fullWidth"> 39 <div class="video-info">
31 <div class="col-12 col-lg-auto video-info"> 40 <div class="video-info-first-row">
32 <div class="video-info-first-row"> 41 <div>
33 <div> 42 <div class="d-block d-md-none"> <!-- only shown on medium devices, has its conterpart for larger viewports below -->
34 <div class="d-block d-sm-none"> <!-- only shown on small devices, has its conterpart for larger viewports below --> 43 <h1 class="video-info-name">{{ video.name }}</h1>
44 <div i18n class="video-info-date-views">
45 Published {{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views
46 </div>
47 </div>
48
49 <div class="d-flex justify-content-between align-items-md-end">
50 <div class="d-none d-md-block">
35 <h1 class="video-info-name">{{ video.name }}</h1> 51 <h1 class="video-info-name">{{ video.name }}</h1>
36 52
37 <div i18n class="video-info-date-views"> 53 <div i18n class="video-info-date-views">
@@ -39,173 +55,154 @@
39 </div> 55 </div>
40 </div> 56 </div>
41 57
42 <div class="d-flex justify-content-between align-items-sm-end"> 58 <div class="video-actions-rates">
43 <div class="d-none d-sm-block"> 59 <div class="video-actions fullWidth justify-content-end">
44 <h1 class="video-info-name">{{ video.name }}</h1> 60 <div
61 *ngIf="isUserLoggedIn()" [ngClass]="{ 'activated': userRating === 'like' }" (click)="setLike()"
62 class="action-button action-button-like" role="button" [attr.aria-pressed]="userRating === 'like'"
63 i18n-title title="Like this video"
64 >
65 <my-global-icon iconName="like"></my-global-icon>
66 </div>
45 67
46 <div i18n class="video-info-date-views"> 68 <div
47 Published {{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views 69 *ngIf="isUserLoggedIn()" [ngClass]="{ 'activated': userRating === 'dislike' }" (click)="setDislike()"
70 class="action-button action-button-dislike" role="button" [attr.aria-pressed]="userRating === 'dislike'"
71 i18n-title title="Dislike this video"
72 >
73 <my-global-icon iconName="dislike"></my-global-icon>
48 </div> 74 </div>
49 </div>
50 75
51 <div class="video-actions-rates"> 76 <div *ngIf="video.support" (click)="showSupportModal()" class="action-button">
52 <div class="video-actions fullWidth justify-content-end"> 77 <my-global-icon iconName="heart"></my-global-icon>
53 <div 78 <span class="icon-text" i18n>Support</span>
54 *ngIf="isUserLoggedIn()" [ngClass]="{ 'activated': userRating === 'like' }" (click)="setLike()" 79 </div>
55 class="action-button action-button-like" role="button" [attr.aria-pressed]="userRating === 'like'"
56 i18n-title title="Like this video"
57 >
58 <my-global-icon iconName="like"></my-global-icon>
59 </div>
60 80
61 <div 81 <div (click)="showShareModal()" class="action-button" role="button">
62 *ngIf="isUserLoggedIn()" [ngClass]="{ 'activated': userRating === 'dislike' }" (click)="setDislike()" 82 <my-global-icon iconName="share"></my-global-icon>
63 class="action-button action-button-dislike" role="button" [attr.aria-pressed]="userRating === 'dislike'" 83 <span class="icon-text" i18n>Share</span>
64 i18n-title title="Dislike this video" 84 </div>
65 >
66 <my-global-icon iconName="dislike"></my-global-icon>
67 </div>
68 85
69 <div *ngIf="video.support" (click)="showSupportModal()" class="action-button action-button-support"> 86 <div
70 <my-global-icon iconName="heart"></my-global-icon> 87 class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside"
71 <span class="icon-text" i18n>Support</span> 88 *ngIf="isUserLoggedIn()" (openChange)="addContent.openChange($event)"
89 >
90 <div class="action-button action-button-save" ngbDropdownToggle role="button">
91 <my-global-icon iconName="playlist-add"></my-global-icon>
92 <span class="icon-text" i18n>Save</span>
72 </div> 93 </div>
73 94
74 <div (click)="showShareModal()" class="action-button action-button-share" role="button"> 95 <div ngbDropdownMenu>
75 <my-global-icon iconName="share"></my-global-icon> 96 <my-video-add-to-playlist #addContent [video]="video"></my-video-add-to-playlist>
76 <span class="icon-text" i18n>Share</span>
77 </div>
78
79 <div class="action-more" ngbDropdown placement="top" role="button">
80 <div class="action-button" ngbDropdownToggle role="button">
81 <my-global-icon class="more-icon" iconName="more"></my-global-icon>
82 </div>
83
84 <div ngbDropdownMenu>
85 <a class="dropdown-item" i18n-title title="Download the video" href="#" (click)="showDownloadModal($event)">
86 <my-global-icon iconName="download"></my-global-icon> <ng-container i18n>Download</ng-container>
87 </a>
88
89 <a *ngIf="isUserLoggedIn()" class="dropdown-item" i18n-title title="Report this video" href="#" (click)="showReportModal($event)">
90 <my-global-icon iconName="alert"></my-global-icon> <ng-container i18n>Report</ng-container>
91 </a>
92
93 <a *ngIf="isVideoUpdatable()" class="dropdown-item" i18n-title title="Update this video" href="#" [routerLink]="[ '/videos/update', video.uuid ]">
94 <my-global-icon iconName="edit"></my-global-icon> <ng-container i18n>Update</ng-container>
95 </a>
96
97 <a *ngIf="isVideoBlacklistable()" class="dropdown-item" i18n-title title="Blacklist this video" href="#" (click)="showBlacklistModal($event)">
98 <my-global-icon iconName="no"></my-global-icon> <ng-container i18n>Blacklist</ng-container>
99 </a>
100
101 <a *ngIf="isVideoUnblacklistable()" class="dropdown-item" i18n-title title="Unblacklist this video" href="#" (click)="unblacklistVideo($event)">
102 <my-global-icon iconName="undo"></my-global-icon> <ng-container i18n>Unblacklist</ng-container>
103 </a>
104
105 <a *ngIf="isVideoRemovable()" class="dropdown-item" i18n-title title="Delete this video" href="#" (click)="removeVideo($event)">
106 <my-global-icon iconName="delete"></my-global-icon> <ng-container i18n>Delete</ng-container>
107 </a>
108 </div>
109 </div> 97 </div>
110 </div> 98 </div>
111 99
112 <div 100 <my-video-actions-dropdown
113 class="video-info-likes-dislikes-bar" 101 placement="top" buttonDirection="horizontal" [buttonStyled]="true" [video]="video" (videoRemoved)="onVideoRemoved()"
114 *ngIf="video.likes !== 0 || video.dislikes !== 0" 102 ></my-video-actions-dropdown>
115 [ngbTooltip]="likesBarTooltipText"
116 placement="bottom"
117 >
118 <div class="likes-bar" [ngStyle]="{ 'width.%': video.likesPercent }"></div>
119 </div>
120 </div> 103 </div>
121 </div>
122 104
105 <div
106 class="video-info-likes-dislikes-bar"
107 *ngIf="video.likes !== 0 || video.dislikes !== 0"
108 [ngbTooltip]="likesBarTooltipText"
109 placement="bottom"
110 >
111 <div class="likes-bar" [ngStyle]="{ 'width.%': video.likesPercent }"></div>
112 </div>
113 </div>
114 </div>
123 115
124 <div class="pt-3 border-top video-info-channel">
125 <a [routerLink]="[ '/video-channels', video.byVideoChannel ]" i18n-title title="Go the channel page">
126 {{ video.channel.displayName }}
127 116
128 <img [src]="video.videoChannelAvatarUrl" alt="Video channel avatar" /> 117 <div class="pt-3 border-top video-info-channel">
129 </a> 118 <a [routerLink]="[ '/video-channels', video.byVideoChannel ]" i18n-title title="Go the channel page">
119 {{ video.channel.displayName }}
130 120
131 <my-subscribe-button #subscribeButton [videoChannel]="video.channel" size="small"></my-subscribe-button> 121 <img [src]="video.videoChannelAvatarUrl" alt="Video channel avatar" />
132 </div> 122 </a>
133 123
134 <div class="video-info-by"> 124 <my-subscribe-button #subscribeButton [videoChannel]="video.channel" size="small"></my-subscribe-button>
135 <a [routerLink]="[ '/accounts', video.byAccount ]" i18n-title title="Go to the account page">
136 <span i18n>By {{ video.byAccount }}</span>
137 <img [src]="video.accountAvatarUrl" alt="Account avatar" />
138 </a>
139 </div>
140 </div> 125 </div>
141 126
127 <div class="video-info-by">
128 <a [routerLink]="[ '/accounts', video.byAccount ]" i18n-title title="Go to the account page">
129 <span i18n>By {{ video.byAccount }}</span>
130 <img [src]="video.accountAvatarUrl" alt="Account avatar" />
131 </a>
132 </div>
142 </div> 133 </div>
143 134
144 <div class="video-info-description"> 135 </div>
145 <div class="video-info-description-html" [innerHTML]="videoHTMLDescription"></div>
146 136
147 <div class="video-info-description-more" *ngIf="completeDescriptionShown === false && video.description?.length >= 250" (click)="showMoreDescription()"> 137 <div class="video-info-description">
148 <ng-container i18n>Show more</ng-container> 138 <div class="video-info-description-html" [innerHTML]="videoHTMLDescription"></div>
149 <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-down"></span>
150 <my-loader class="description-loading" [loading]="descriptionLoading"></my-loader>
151 </div>
152 139
153 <div *ngIf="completeDescriptionShown === true" (click)="showLessDescription()" class="video-info-description-more"> 140 <div class="video-info-description-more" *ngIf="completeDescriptionShown === false && video.description?.length >= 250" (click)="showMoreDescription()">
154 <ng-container i18n>Show less</ng-container> 141 <ng-container i18n>Show more</ng-container>
155 <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-up"></span> 142 <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-down"></span>
156 </div> 143 <my-small-loader class="description-loading" [loading]="descriptionLoading"></my-small-loader>
157 </div> 144 </div>
158 145
159 <div class="video-attributes"> 146 <div *ngIf="completeDescriptionShown === true" (click)="showLessDescription()" class="video-info-description-more">
160 <div class="video-attribute"> 147 <ng-container i18n>Show less</ng-container>
161 <span i18n class="video-attribute-label">Privacy</span> 148 <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-up"></span>
162 <span class="video-attribute-value">{{ video.privacy.label }}</span> 149 </div>
163 </div> 150 </div>
164 151
165 <div class="video-attribute"> 152 <div class="video-attributes">
166 <span i18n class="video-attribute-label">Category</span> 153 <div class="video-attribute">
167 <span *ngIf="!video.category.id" class="video-attribute-value">{{ video.category.label }}</span> 154 <span i18n class="video-attribute-label">Privacy</span>
168 <a 155 <span class="video-attribute-value">{{ video.privacy.label }}</span>
169 *ngIf="video.category.id" class="video-attribute-value" 156 </div>
170 [routerLink]="[ '/search' ]" [queryParams]="{ categoryOneOf: [ video.category.id ] }"
171 >{{ video.category.label }}</a>
172 </div>
173 157
174 <div class="video-attribute"> 158 <div *ngIf="!!video.originallyPublishedAt" class="video-attribute">
175 <span i18n class="video-attribute-label">Licence</span> 159 <span i18n class="video-attribute-label">Originally published</span>
176 <span *ngIf="!video.licence.id" class="video-attribute-value">{{ video.licence.label }}</span> 160 <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span>
177 <a 161 </div>
178 *ngIf="video.licence.id" class="video-attribute-value"
179 [routerLink]="[ '/search' ]" [queryParams]="{ licenceOneOf: [ video.licence.id ] }"
180 >{{ video.licence.label }}</a>
181 </div>
182 162
183 <div class="video-attribute"> 163 <div class="video-attribute">
184 <span i18n class="video-attribute-label">Language</span> 164 <span i18n class="video-attribute-label">Category</span>
185 <span *ngIf="!video.language.id" class="video-attribute-value">{{ video.language.label }}</span> 165 <span *ngIf="!video.category.id" class="video-attribute-value">{{ video.category.label }}</span>
186 <a 166 <a
187 *ngIf="video.language.id" class="video-attribute-value" 167 *ngIf="video.category.id" class="video-attribute-value"
188 [routerLink]="[ '/search' ]" [queryParams]="{ languageOneOf: [ video.language.id ] }" 168 [routerLink]="[ '/search' ]" [queryParams]="{ categoryOneOf: [ video.category.id ] }"
189 >{{ video.language.label }}</a> 169 >{{ video.category.label }}</a>
190 </div> 170 </div>
191 171
192 <div class="video-attribute video-attribute-tags"> 172 <div class="video-attribute">
193 <span i18n class="video-attribute-label">Tags</span> 173 <span i18n class="video-attribute-label">Licence</span>
194 <a 174 <span *ngIf="!video.licence.id" class="video-attribute-value">{{ video.licence.label }}</span>
195 *ngFor="let tag of getVideoTags()" 175 <a
196 class="video-attribute-value" [routerLink]="[ '/search' ]" [queryParams]="{ tagsOneOf: [ tag ] }" 176 *ngIf="video.licence.id" class="video-attribute-value"
197 >{{ tag }}</a> 177 [routerLink]="[ '/search' ]" [queryParams]="{ licenceOneOf: [ video.licence.id ] }"
198 </div> 178 >{{ video.licence.label }}</a>
199 </div> 179 </div>
200 180
201 <my-video-comments [video]="video" [user]="user"></my-video-comments> 181 <div class="video-attribute">
182 <span i18n class="video-attribute-label">Language</span>
183 <span *ngIf="!video.language.id" class="video-attribute-value">{{ video.language.label }}</span>
184 <a
185 *ngIf="video.language.id" class="video-attribute-value"
186 [routerLink]="[ '/search' ]" [queryParams]="{ languageOneOf: [ video.language.id ] }"
187 >{{ video.language.label }}</a>
188 </div>
189
190 <div class="video-attribute video-attribute-tags">
191 <span i18n class="video-attribute-label">Tags</span>
192 <a
193 *ngFor="let tag of getVideoTags()"
194 class="video-attribute-value" [routerLink]="[ '/search' ]" [queryParams]="{ tagsOneOf: [ tag ] }"
195 >{{ tag }}</a>
196 </div>
202 </div> 197 </div>
203 198
204 <my-recommended-videos [inputRecommendation]="{ uuid: video.uuid, tags: video.tags }" [user]="user"></my-recommended-videos> 199 <my-video-comments [video]="video" [user]="user"></my-video-comments>
205 </div> 200 </div>
201
202 <my-recommended-videos [inputRecommendation]="{ uuid: video.uuid, tags: video.tags }" [user]="user"></my-recommended-videos>
206 </div> 203 </div>
207 204
208 <div class="privacy-concerns" *ngIf="hasAlreadyAcceptedPrivacyConcern === false"> 205 <div class="row privacy-concerns" *ngIf="hasAlreadyAcceptedPrivacyConcern === false">
209 <div class="privacy-concerns-text"> 206 <div class="privacy-concerns-text">
210 <strong i18n>Friendly Reminder: </strong> 207 <strong i18n>Friendly Reminder: </strong>
211 <ng-container i18n> 208 <ng-container i18n>
@@ -218,11 +215,9 @@
218 OK 215 OK
219 </div> 216 </div>
220 </div> 217 </div>
218</div>
221 219
222<ng-template [ngIf]="video !== null"> 220<ng-template [ngIf]="video !== null">
223 <my-video-support #videoSupportModal [video]="video"></my-video-support> 221 <my-video-support #videoSupportModal [video]="video"></my-video-support>
224 <my-video-share #videoShareModal [video]="video"></my-video-share> 222 <my-video-share #videoShareModal [video]="video"></my-video-share>
225 <my-video-download #videoDownloadModal [video]="video"></my-video-download>
226 <my-video-report #videoReportModal [video]="video"></my-video-report>
227 <my-video-blacklist #videoBlacklistModal [video]="video"></my-video-blacklist>
228</ng-template> 223</ng-template>
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 b03ed197d..8ca5c4118 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.scss
+++ b/client/src/app/videos/+video-watch/video-watch.component.scss
@@ -1,22 +1,63 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3@import '_bootstrap-variables'; 3@import '_bootstrap-variables';
4@import '_miniature';
4 5
5$other-videos-width: 260px; 6$other-videos-width: 260px;
7$player-factor: 1.7; // 16/9
6 8
7.root-row { 9@function getPlayerHeight($width){
8 flex-direction: column; 10 @return calc(#{$width} / #{$player-factor})
11}
12
13@function getPlayerWidth($height){
14 @return calc(#{$height} * #{$player-factor})
15}
16
17@mixin playlist-below-player {
18 width: 100% !important;
19 height: auto !important;
20 max-height: 300px !important;
21 border-bottom: 1px solid $separator-border-color !important;
22}
23
24.root {
25 &.theater-enabled #video-wrapper {
26 flex-direction: column;
27 justify-content: center;
28
29 #videojs-wrapper {
30 width: 100%;
31 }
32
33 /deep/ .video-js {
34 $height: calc(100vh - #{$header-height} - #{$theater-bottom-space});
35
36 height: $height;
37 width: 100%;
38 }
39
40 my-video-watch-playlist /deep/ .playlist {
41 @include playlist-below-player;
42 }
43 }
9} 44}
10 45
11.blacklisted-label { 46.blacklisted-label {
12 font-weight: $font-semibold; 47 font-weight: $font-semibold;
13} 48}
14 49
15#video-element-wrapper { 50#video-wrapper {
16 background-color: #000; 51 background-color: #000;
17 display: flex; 52 display: flex;
18 justify-content: center; 53 justify-content: center;
19 flex-grow: 1; 54 margin: 0 -15px;
55
56 #videojs-wrapper {
57 display: flex;
58 justify-content: center;
59 flex-grow: 1;
60 }
20 61
21 .remote-server-down { 62 .remote-server-down {
22 color: #fff; 63 color: #fff;
@@ -40,13 +81,8 @@ $other-videos-width: 260px;
40 } 81 }
41 82
42 /deep/ .video-js { 83 /deep/ .video-js {
43 width: 888px; 84 width: getPlayerWidth(66vh);
44 height: 500px; 85 height: 66vh;
45
46 &.vjs-theater-enabled {
47 height: calc(100vh - #{$header-height} - #{$theater-bottom-space});
48 width: 100%;
49 }
50 86
51 // VideoJS create an inner video player 87 // VideoJS create an inner video player
52 video { 88 video {
@@ -59,13 +95,14 @@ $other-videos-width: 260px;
59 .remote-server-down, 95 .remote-server-down,
60 /deep/ .video-js { 96 /deep/ .video-js {
61 width: 100vw; 97 width: 100vw;
62 height: calc(100vw / 1.7); // 16/9 98 height: getPlayerHeight(100vw)
63 } 99 }
64 } 100 }
65} 101}
66 102
67.alert { 103.alert {
68 text-align: center; 104 text-align: center;
105 border-radius: 0;
69} 106}
70 107
71#video-not-found { 108#video-not-found {
@@ -78,6 +115,7 @@ $other-videos-width: 260px;
78} 115}
79 116
80.video-bottom { 117.video-bottom {
118 display: flex;
81 margin-top: 40px; 119 margin-top: 40px;
82 120
83 .video-info { 121 .video-info {
@@ -176,7 +214,9 @@ $other-videos-width: 260px;
176 display: flex; 214 display: flex;
177 align-items: center; 215 align-items: center;
178 216
179 .action-button:not(:first-child), .action-more { 217 .action-button:not(:first-child),
218 .action-dropdown,
219 my-video-actions-dropdown {
180 margin-left: 10px; 220 margin-left: 10px;
181 } 221 }
182 222
@@ -212,25 +252,16 @@ $other-videos-width: 260px;
212 } 252 }
213 } 253 }
214 254
215 .icon-text { 255 &.action-button-save {
216 margin-left: 3px;
217 }
218 }
219
220 .action-more {
221 display: inline-block;
222
223 .dropdown-menu .dropdown-item {
224 padding: 6px 24px;
225
226 my-global-icon { 256 my-global-icon {
227 width: 24px; 257 top: 0 !important;
228 258 right: -1px;
229 margin-right: 10px;
230 position: relative;
231 top: -2px;
232 } 259 }
233 } 260 }
261
262 .icon-text {
263 margin-left: 3px;
264 }
234 } 265 }
235 } 266 }
236 267
@@ -286,7 +317,7 @@ $other-videos-width: 260px;
286 margin-bottom: 12px; 317 margin-bottom: 12px;
287 318
288 .video-attribute-label { 319 .video-attribute-label {
289 min-width: 91px; 320 min-width: 142px;
290 padding-right: 5px; 321 padding-right: 5px;
291 display: inline-block; 322 display: inline-block;
292 color: $grey-foreground-color; 323 color: $grey-foreground-color;
@@ -314,7 +345,7 @@ $other-videos-width: 260px;
314 345
315 /deep/ .other-videos { 346 /deep/ .other-videos {
316 padding-left: 15px; 347 padding-left: 15px;
317 width: $other-videos-width; 348 flex-basis: $other-videos-width;
318 349
319 .title-page { 350 .title-page {
320 margin-top: 0 !important; 351 margin-top: 0 !important;
@@ -322,14 +353,11 @@ $other-videos-width: 260px;
322 353
323 .video-miniature { 354 .video-miniature {
324 display: flex; 355 display: flex;
356 width: $other-videos-width;
325 height: 100%; 357 height: 100%;
326 margin-bottom: 20px; 358 margin-bottom: 20px;
327 flex-wrap: wrap; 359 flex-wrap: wrap;
328 360
329 .video-miniature-information {
330 flex-grow: 1;
331 }
332
333 .video-thumbnail { 361 .video-thumbnail {
334 margin-right: 10px 362 margin-right: 10px
335 } 363 }
@@ -350,14 +378,14 @@ my-video-comments {
350 378
351@media screen and (max-width: $small-view) { 379@media screen and (max-width: $small-view) {
352 .privacy-concerns { 380 .privacy-concerns {
353 margin-left: $menu-width; 381 margin-left: $menu-width - 15px; // Menu is absolute
354 } 382 }
355} 383}
356 384
357:host-context(.expanded) { 385:host-context(.expanded) {
358 .privacy-concerns { 386 .privacy-concerns {
359 width: 100%; 387 width: 100%;
360 margin-left: 0; 388 margin-left: -15px;
361 } 389 }
362} 390}
363 391
@@ -368,6 +396,7 @@ my-video-comments {
368 padding: 5px 15px; 396 padding: 5px 15px;
369 397
370 display: flex; 398 display: flex;
399 flex-wrap: nowrap;
371 align-items: center; 400 align-items: center;
372 justify-content: flex-start; 401 justify-content: flex-start;
373 background-color: rgba(0, 0, 0, 0.9); 402 background-color: rgba(0, 0, 0, 0.9);
@@ -403,12 +432,6 @@ my-video-comments {
403 } 432 }
404} 433}
405 434
406@media screen and (min-width: map-get($grid-breakpoints, xl)) {
407 .video-bottom .video-info {
408 max-width: calc(100% - #{$other-videos-width});
409 }
410}
411
412@media screen and (max-width: 1600px) { 435@media screen and (max-width: 1600px) {
413 .video-bottom .video-info .video-attributes .video-attribute { 436 .video-bottom .video-info .video-attributes .video-attribute {
414 margin-bottom: 5px; 437 margin-bottom: 5px;
@@ -426,9 +449,33 @@ my-video-comments {
426 } 449 }
427} 450}
428 451
452@media screen and (max-width: 1100px) {
453 #video-wrapper {
454 flex-direction: column;
455 justify-content: center;
456
457 my-video-watch-playlist /deep/ .playlist {
458 @include playlist-below-player;
459 }
460 }
461
462 .video-bottom {
463 flex-direction: column;
464
465 /deep/ .other-videos {
466 padding-left: 0 !important;
467
468 /deep/ .video-miniature {
469 flex-direction: row;
470 width: auto;
471 }
472 }
473 }
474}
475
429@media screen and (max-width: 600px) { 476@media screen and (max-width: 600px) {
430 .video-bottom { 477 .video-bottom {
431 margin: 20px 0 0 0; 478 margin: 20px 0 0 0 !important;
432 479
433 .video-info { 480 .video-info {
434 padding: 0; 481 padding: 0;
@@ -443,12 +490,8 @@ my-video-comments {
443 } 490 }
444 } 491 }
445 492
446 /deep/ .other-videos { 493 /deep/ .other-videos .video-miniature {
447 padding-left: 0 !important; 494 flex-direction: column;
448
449 /deep/ .video-miniature {
450 flex-direction: column;
451 }
452 } 495 }
453 496
454 .privacy-concerns { 497 .privacy-concerns {
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 ee504bc58..b147b75b0 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -7,29 +7,29 @@ import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-supp
7import { MetaService } from '@ngx-meta/core' 7import { MetaService } from '@ngx-meta/core'
8import { Notifier, ServerService } from '@app/core' 8import { Notifier, ServerService } from '@app/core'
9import { forkJoin, Subscription } from 'rxjs' 9import { forkJoin, Subscription } from 'rxjs'
10// FIXME: something weird with our path definition in tsconfig and typings
11// @ts-ignore
12import videojs from 'video.js'
13import 'videojs-hotkeys'
14import { Hotkey, HotkeysService } from 'angular2-hotkeys' 10import { Hotkey, HotkeysService } from 'angular2-hotkeys'
15import * as WebTorrent from 'webtorrent'
16import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared' 11import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared'
17import '../../../assets/player/peertube-videojs-plugin'
18import { AuthService, ConfirmService } from '../../core' 12import { AuthService, ConfirmService } from '../../core'
19import { RestExtractor, VideoBlacklistService } from '../../shared' 13import { RestExtractor, VideoBlacklistService } from '../../shared'
20import { VideoDetails } from '../../shared/video/video-details.model' 14import { VideoDetails } from '../../shared/video/video-details.model'
21import { VideoService } from '../../shared/video/video.service' 15import { VideoService } from '../../shared/video/video.service'
22import { VideoDownloadComponent } from './modal/video-download.component'
23import { VideoReportComponent } from './modal/video-report.component'
24import { VideoShareComponent } from './modal/video-share.component' 16import { VideoShareComponent } from './modal/video-share.component'
25import { VideoBlacklistComponent } from './modal/video-blacklist.component'
26import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component' 17import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component'
27import { addContextMenu, getVideojsOptions, loadLocaleInVideoJS } from '../../../assets/player/peertube-player'
28import { I18n } from '@ngx-translate/i18n-polyfill' 18import { I18n } from '@ngx-translate/i18n-polyfill'
29import { environment } from '../../../environments/environment' 19import { environment } from '../../../environments/environment'
30import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
31import { VideoCaptionService } from '@app/shared/video-caption' 20import { VideoCaptionService } from '@app/shared/video-caption'
32import { MarkdownService } from '@app/shared/renderer' 21import { MarkdownService } from '@app/shared/renderer'
22import {
23 P2PMediaLoaderOptions,
24 PeertubePlayerManager,
25 PeertubePlayerManagerOptions,
26 PlayerMode
27} from '../../../assets/player/peertube-player-manager'
28import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
29import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
30import { Video } from '@app/shared/video/video.model'
31import { isWebRTCDisabled } from '../../../assets/player/utils'
32import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component'
33 33
34@Component({ 34@Component({
35 selector: 'my-video-watch', 35 selector: 'my-video-watch',
@@ -39,19 +39,20 @@ import { MarkdownService } from '@app/shared/renderer'
39export class VideoWatchComponent implements OnInit, OnDestroy { 39export class VideoWatchComponent implements OnInit, OnDestroy {
40 private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern' 40 private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern'
41 41
42 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent 42 @ViewChild('videoWatchPlaylist') videoWatchPlaylist: VideoWatchPlaylistComponent
43 @ViewChild('videoShareModal') videoShareModal: VideoShareComponent 43 @ViewChild('videoShareModal') videoShareModal: VideoShareComponent
44 @ViewChild('videoReportModal') videoReportModal: VideoReportComponent
45 @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent 44 @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent
46 @ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent
47 @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent 45 @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
48 46
49 player: videojs.Player 47 player: any
50 playerElement: HTMLVideoElement 48 playerElement: HTMLVideoElement
49 theaterEnabled = false
51 userRating: UserVideoRateType = null 50 userRating: UserVideoRateType = null
52 video: VideoDetails = null 51 video: VideoDetails = null
53 descriptionLoading = false 52 descriptionLoading = false
54 53
54 playlist: VideoPlaylist = null
55
55 completeDescriptionShown = false 56 completeDescriptionShown = false
56 completeVideoDescription: string 57 completeVideoDescription: string
57 shortVideoDescription: string 58 shortVideoDescription: string
@@ -61,8 +62,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
61 remoteServerDown = false 62 remoteServerDown = false
62 hotkeys: Hotkey[] 63 hotkeys: Hotkey[]
63 64
64 private videojsLocaleLoaded = false 65 private currentTime: number
65 private paramsSub: Subscription 66 private paramsSub: Subscription
67 private queryParamsSub: Subscription
68 private configSub: Subscription
66 69
67 constructor ( 70 constructor (
68 private elementRef: ElementRef, 71 private elementRef: ElementRef,
@@ -70,6 +73,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
70 private route: ActivatedRoute, 73 private route: ActivatedRoute,
71 private router: Router, 74 private router: Router,
72 private videoService: VideoService, 75 private videoService: VideoService,
76 private playlistService: VideoPlaylistService,
73 private videoBlacklistService: VideoBlacklistService, 77 private videoBlacklistService: VideoBlacklistService,
74 private confirmService: ConfirmService, 78 private confirmService: ConfirmService,
75 private metaService: MetaService, 79 private metaService: MetaService,
@@ -91,37 +95,28 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
91 } 95 }
92 96
93 ngOnInit () { 97 ngOnInit () {
94 if ( 98 this.configSub = this.serverService.configLoaded
95 WebTorrent.WEBRTC_SUPPORT === false || 99 .subscribe(() => {
96 peertubeLocalStorage.getItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY) === 'true' 100 if (
97 ) { 101 isWebRTCDisabled() ||
98 this.hasAlreadyAcceptedPrivacyConcern = true 102 this.serverService.getConfig().tracker.enabled === false ||
99 } 103 peertubeLocalStorage.getItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY) === 'true'
104 ) {
105 this.hasAlreadyAcceptedPrivacyConcern = true
106 }
107 })
100 108
101 this.paramsSub = this.route.params.subscribe(routeParams => { 109 this.paramsSub = this.route.params.subscribe(routeParams => {
102 const uuid = routeParams[ 'uuid' ] 110 const videoId = routeParams[ 'videoId' ]
103 111 if (videoId) this.loadVideo(videoId)
104 // Video did not change
105 if (this.video && this.video.uuid === uuid) return
106
107 if (this.player) this.player.pause()
108 112
109 // Video did change 113 const playlistId = routeParams[ 'playlistId' ]
110 forkJoin( 114 if (playlistId) this.loadPlaylist(playlistId)
111 this.videoService.getVideo(uuid), 115 })
112 this.videoCaptionService.listCaptions(uuid)
113 )
114 .pipe(
115 // If 401, the video is private or blacklisted so redirect to 404
116 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
117 )
118 .subscribe(([ video, captionsResult ]) => {
119 const startTime = this.route.snapshot.queryParams.start
120 const subtitle = this.route.snapshot.queryParams.subtitle
121 116
122 this.onVideoFetched(video, captionsResult.data, { startTime, subtitle }) 117 this.queryParamsSub = this.route.queryParams.subscribe(queryParams => {
123 .catch(err => this.handleError(err)) 118 const videoId = queryParams[ 'videoId' ]
124 }) 119 if (videoId) this.loadVideo(videoId)
125 }) 120 })
126 121
127 this.hotkeys = [ 122 this.hotkeys = [
@@ -147,7 +142,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
147 this.flushPlayer() 142 this.flushPlayer()
148 143
149 // Unsubscribe subscriptions 144 // Unsubscribe subscriptions
150 this.paramsSub.unsubscribe() 145 if (this.paramsSub) this.paramsSub.unsubscribe()
146 if (this.queryParamsSub) this.queryParamsSub.unsubscribe()
151 147
152 // Unbind hotkeys 148 // Unbind hotkeys
153 if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys) 149 if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys)
@@ -209,96 +205,26 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
209 ) 205 )
210 } 206 }
211 207
212 showReportModal (event: Event) {
213 event.preventDefault()
214 this.videoReportModal.show()
215 }
216
217 showSupportModal () { 208 showSupportModal () {
218 this.videoSupportModal.show() 209 this.videoSupportModal.show()
219 } 210 }
220 211
221 showShareModal () { 212 showShareModal () {
222 const currentTime = this.player ? this.player.currentTime() : undefined 213 this.videoShareModal.show(this.currentTime)
223
224 this.videoShareModal.show(currentTime)
225 }
226
227 showDownloadModal (event: Event) {
228 event.preventDefault()
229 this.videoDownloadModal.show()
230 }
231
232 showBlacklistModal (event: Event) {
233 event.preventDefault()
234 this.videoBlacklistModal.show()
235 }
236
237 async unblacklistVideo (event: Event) {
238 event.preventDefault()
239
240 const confirmMessage = this.i18n(
241 'Do you really want to remove this video from the blacklist? It will be available again in the videos list.'
242 )
243
244 const res = await this.confirmService.confirm(confirmMessage, this.i18n('Unblacklist'))
245 if (res === false) return
246
247 this.videoBlacklistService.removeVideoFromBlacklist(this.video.id).subscribe(
248 () => {
249 this.notifier.success(this.i18n('Video {{name}} removed from the blacklist.', { name: this.video.name }))
250
251 this.video.blacklisted = false
252 this.video.blacklistedReason = null
253 },
254
255 err => this.notifier.error(err.message)
256 )
257 } 214 }
258 215
259 isUserLoggedIn () { 216 isUserLoggedIn () {
260 return this.authService.isLoggedIn() 217 return this.authService.isLoggedIn()
261 } 218 }
262 219
263 isVideoUpdatable () {
264 return this.video.isUpdatableBy(this.authService.getUser())
265 }
266
267 isVideoBlacklistable () {
268 return this.video.isBlackistableBy(this.user)
269 }
270
271 isVideoUnblacklistable () {
272 return this.video.isUnblacklistableBy(this.user)
273 }
274
275 getVideoTags () { 220 getVideoTags () {
276 if (!this.video || Array.isArray(this.video.tags) === false) return [] 221 if (!this.video || Array.isArray(this.video.tags) === false) return []
277 222
278 return this.video.tags 223 return this.video.tags
279 } 224 }
280 225
281 isVideoRemovable () { 226 onVideoRemoved () {
282 return this.video.isRemovableBy(this.authService.getUser()) 227 this.redirectService.redirectToHomepage()
283 }
284
285 async removeVideo (event: Event) {
286 event.preventDefault()
287
288 const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this video?'), this.i18n('Delete'))
289 if (res === false) return
290
291 this.videoService.removeVideo(this.video.id)
292 .subscribe(
293 () => {
294 this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: this.video.name }))
295
296 // Go back to the video-list.
297 this.redirectService.redirectToHomepage()
298 },
299
300 error => this.notifier.error(error.message)
301 )
302 } 228 }
303 229
304 acceptedPrivacyConcern () { 230 acceptedPrivacyConcern () {
@@ -318,13 +244,61 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
318 return this.video && this.video.scheduledUpdate !== undefined 244 return this.video && this.video.scheduledUpdate !== undefined
319 } 245 }
320 246
247 isVideoBlur (video: Video) {
248 return video.isVideoNSFWForUser(this.user, this.serverService.getConfig())
249 }
250
251 private loadVideo (videoId: string) {
252 // Video did not change
253 if (this.video && this.video.uuid === videoId) return
254
255 if (this.player) this.player.pause()
256
257 // Video did change
258 forkJoin(
259 this.videoService.getVideo(videoId),
260 this.videoCaptionService.listCaptions(videoId)
261 )
262 .pipe(
263 // If 401, the video is private or blacklisted so redirect to 404
264 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
265 )
266 .subscribe(([ video, captionsResult ]) => {
267 const queryParams = this.route.snapshot.queryParams
268 const startTime = queryParams.start
269 const stopTime = queryParams.stop
270 const subtitle = queryParams.subtitle
271 const playerMode = queryParams.mode
272
273 this.onVideoFetched(video, captionsResult.data, { startTime, stopTime, subtitle, playerMode })
274 .catch(err => this.handleError(err))
275 })
276 }
277
278 private loadPlaylist (playlistId: string) {
279 // Playlist did not change
280 if (this.playlist && this.playlist.uuid === playlistId) return
281
282 this.playlistService.getVideoPlaylist(playlistId)
283 .pipe(
284 // If 401, the video is private or blacklisted so redirect to 404
285 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
286 )
287 .subscribe(playlist => {
288 this.playlist = playlist
289
290 const videoId = this.route.snapshot.queryParams['videoId']
291 this.videoWatchPlaylist.loadPlaylistElements(playlist, !videoId)
292 })
293 }
294
321 private updateVideoDescription (description: string) { 295 private updateVideoDescription (description: string) {
322 this.video.description = description 296 this.video.description = description
323 this.setVideoDescriptionHTML() 297 this.setVideoDescriptionHTML()
324 } 298 }
325 299
326 private setVideoDescriptionHTML () { 300 private async setVideoDescriptionHTML () {
327 this.videoHTMLDescription = this.markdownService.textMarkdownToHTML(this.video.description) 301 this.videoHTMLDescription = await this.markdownService.textMarkdownToHTML(this.video.description)
328 } 302 }
329 303
330 private setVideoLikesBarTooltipText () { 304 private setVideoLikesBarTooltipText () {
@@ -366,19 +340,26 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
366 ) 340 )
367 } 341 }
368 342
369 private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], urlOptions: { startTime: number, subtitle: string }) { 343 private async onVideoFetched (
344 video: VideoDetails,
345 videoCaptions: VideoCaption[],
346 urlOptions: { startTime?: number, stopTime?: number, subtitle?: string, playerMode?: string }
347 ) {
370 this.video = video 348 this.video = video
371 349
372 // Re init attributes 350 // Re init attributes
373 this.descriptionLoading = false 351 this.descriptionLoading = false
374 this.completeDescriptionShown = false 352 this.completeDescriptionShown = false
375 this.remoteServerDown = false 353 this.remoteServerDown = false
354 this.currentTime = undefined
355
356 this.videoWatchPlaylist.updatePlaylistIndex(video)
376 357
377 let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0) 358 let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
378 // If we are at the end of the video, reset the timer 359 // If we are at the end of the video, reset the timer
379 if (this.video.duration - startTime <= 1) startTime = 0 360 if (this.video.duration - startTime <= 1) startTime = 0
380 361
381 if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) { 362 if (this.isVideoBlur(this.video)) {
382 const res = await this.confirmService.confirm( 363 const res = await this.confirmService.confirm(
383 this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), 364 this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'),
384 this.i18n('Mature or explicit content') 365 this.i18n('Mature or explicit content')
@@ -390,7 +371,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
390 this.flushPlayer() 371 this.flushPlayer()
391 372
392 // Build video element, because videojs remove it on dispose 373 // Build video element, because videojs remove it on dispose
393 const playerElementWrapper = this.elementRef.nativeElement.querySelector('#video-element-wrapper') 374 const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper')
394 this.playerElement = document.createElement('video') 375 this.playerElement = document.createElement('video')
395 this.playerElement.className = 'video-js vjs-peertube-skin' 376 this.playerElement.className = 'video-js vjs-peertube-skin'
396 this.playerElement.setAttribute('playsinline', 'true') 377 this.playerElement.setAttribute('playsinline', 'true')
@@ -402,40 +383,94 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
402 src: environment.apiUrl + c.captionPath 383 src: environment.apiUrl + c.captionPath
403 })) 384 }))
404 385
405 const videojsOptions = getVideojsOptions({ 386 const options: PeertubePlayerManagerOptions = {
406 autoplay: this.isAutoplay(), 387 common: {
407 inactivityTimeout: 2500, 388 autoplay: this.isAutoplay(),
408 videoFiles: this.video.files, 389
409 videoCaptions: playerCaptions, 390 playerElement: this.playerElement,
410 playerElement: this.playerElement, 391 onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element,
411 videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null, 392
412 videoDuration: this.video.duration, 393 videoDuration: this.video.duration,
413 enableHotkeys: true, 394 enableHotkeys: true,
414 peertubeLink: false, 395 inactivityTimeout: 2500,
415 poster: this.video.previewUrl, 396 poster: this.video.previewUrl,
416 startTime, 397 startTime,
417 subtitle: urlOptions.subtitle, 398 stopTime: urlOptions.stopTime,
418 theaterMode: true, 399
419 language: this.localeId, 400 theaterMode: true,
420 401 captions: videoCaptions.length !== 0,
421 userWatching: this.user && this.user.videosHistoryEnabled === true ? { 402 peertubeLink: false,
422 url: this.videoService.getUserWatchingVideoUrl(this.video.uuid), 403
423 authorizationHeader: this.authService.getRequestHeaderValue() 404 videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null,
424 } : undefined 405 embedUrl: this.video.embedUrl,
425 })
426 406
427 if (this.videojsLocaleLoaded === false) { 407 language: this.localeId,
428 await loadLocaleInVideoJS(environment.apiUrl, videojs, isOnDevLocale() ? getDevLocale() : this.localeId) 408
429 this.videojsLocaleLoaded = true 409 subtitle: urlOptions.subtitle,
410
411 userWatching: this.user && this.user.videosHistoryEnabled === true ? {
412 url: this.videoService.getUserWatchingVideoUrl(this.video.uuid),
413 authorizationHeader: this.authService.getRequestHeaderValue()
414 } : undefined,
415
416 serverUrl: environment.apiUrl,
417
418 videoCaptions: playerCaptions
419 },
420
421 webtorrent: {
422 videoFiles: this.video.files
423 }
424 }
425
426 let mode: PlayerMode
427
428 if (urlOptions.playerMode) {
429 if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader'
430 else mode = 'webtorrent'
431 } else {
432 if (this.video.hasHlsPlaylist()) mode = 'p2p-media-loader'
433 else mode = 'webtorrent'
434 }
435
436 if (mode === 'p2p-media-loader') {
437 const hlsPlaylist = this.video.getHlsPlaylist()
438
439 const p2pMediaLoader = {
440 playlistUrl: hlsPlaylist.playlistUrl,
441 segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
442 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
443 trackerAnnounce: this.video.trackerUrls,
444 videoFiles: this.video.files
445 } as P2PMediaLoaderOptions
446
447 Object.assign(options, { p2pMediaLoader })
430 } 448 }
431 449
432 const self = this
433 this.zone.runOutsideAngular(async () => { 450 this.zone.runOutsideAngular(async () => {
434 videojs(this.playerElement, videojsOptions, function (this: videojs.Player) { 451 this.player = await PeertubePlayerManager.initialize(mode, options)
435 self.player = this 452 this.theaterEnabled = this.player.theaterEnabled
436 this.on('customError', ({ err }: { err: any }) => self.handleError(err)) 453
454 this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
455
456 this.player.on('timeupdate', () => {
457 this.currentTime = Math.floor(this.player.currentTime())
458 })
459
460 this.player.one('ended', () => {
461 if (this.playlist) {
462 this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
463 }
464 })
465
466 this.player.one('stopped', () => {
467 if (this.playlist) {
468 this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
469 }
470 })
437 471
438 addContextMenu(self.player, self.video.embedUrl) 472 this.player.on('theaterChange', (_: any, enabled: boolean) => {
473 this.zone.run(() => this.theaterEnabled = enabled)
439 }) 474 })
440 }) 475 })
441 476
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 2f448db78..67596a3da 100644
--- a/client/src/app/videos/+video-watch/video-watch.module.ts
+++ b/client/src/app/videos/+video-watch/video-watch.module.ts
@@ -1,26 +1,22 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component' 2import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component'
3import { ClipboardModule } from 'ngx-clipboard'
4import { SharedModule } from '../../shared' 3import { SharedModule } from '../../shared'
5import { VideoCommentAddComponent } from './comment/video-comment-add.component' 4import { VideoCommentAddComponent } from './comment/video-comment-add.component'
6import { VideoCommentComponent } from './comment/video-comment.component' 5import { VideoCommentComponent } from './comment/video-comment.component'
7import { VideoCommentService } from './comment/video-comment.service' 6import { VideoCommentService } from './comment/video-comment.service'
8import { VideoCommentsComponent } from './comment/video-comments.component' 7import { VideoCommentsComponent } from './comment/video-comments.component'
9import { VideoDownloadComponent } from './modal/video-download.component'
10import { VideoReportComponent } from './modal/video-report.component'
11import { VideoShareComponent } from './modal/video-share.component' 8import { VideoShareComponent } from './modal/video-share.component'
12import { VideoWatchRoutingModule } from './video-watch-routing.module' 9import { VideoWatchRoutingModule } from './video-watch-routing.module'
13import { VideoWatchComponent } from './video-watch.component' 10import { VideoWatchComponent } from './video-watch.component'
14import { NgxQRCodeModule } from 'ngx-qrcode2' 11import { NgxQRCodeModule } from 'ngx-qrcode2'
15import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' 12import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
16import { VideoBlacklistComponent } from '@app/videos/+video-watch/modal/video-blacklist.component'
17import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module' 13import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module'
14import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component'
18 15
19@NgModule({ 16@NgModule({
20 imports: [ 17 imports: [
21 VideoWatchRoutingModule, 18 VideoWatchRoutingModule,
22 SharedModule, 19 SharedModule,
23 ClipboardModule,
24 NgbTooltipModule, 20 NgbTooltipModule,
25 NgxQRCodeModule, 21 NgxQRCodeModule,
26 RecommendationsModule 22 RecommendationsModule
@@ -28,11 +24,9 @@ import { RecommendationsModule } from '@app/videos/recommendations/recommendatio
28 24
29 declarations: [ 25 declarations: [
30 VideoWatchComponent, 26 VideoWatchComponent,
27 VideoWatchPlaylistComponent,
31 28
32 VideoDownloadComponent,
33 VideoShareComponent, 29 VideoShareComponent,
34 VideoReportComponent,
35 VideoBlacklistComponent,
36 VideoSupportComponent, 30 VideoSupportComponent,
37 VideoCommentsComponent, 31 VideoCommentsComponent,
38 VideoCommentAddComponent, 32 VideoCommentAddComponent,
diff --git a/client/src/app/videos/recommendations/recent-videos-recommendation.service.spec.ts b/client/src/app/videos/recommendations/recent-videos-recommendation.service.spec.ts
deleted file mode 100644
index 698b2e27b..000000000
--- a/client/src/app/videos/recommendations/recent-videos-recommendation.service.spec.ts
+++ /dev/null
@@ -1,66 +0,0 @@
1import { RecentVideosRecommendationService } from '@app/videos/recommendations/recent-videos-recommendation.service'
2import { VideosProvider } from '@app/shared/video/video.service'
3import { EMPTY, of } from 'rxjs'
4import Mock = jest.Mock
5
6describe('"Recent Videos" Recommender', () => {
7 describe('getRecommendations', () => {
8 let videosService: VideosProvider
9 let service: RecentVideosRecommendationService
10 let getVideosMock: Mock<any>
11 beforeEach(() => {
12 getVideosMock = jest.fn(() => EMPTY)
13 videosService = {
14 getVideos: getVideosMock
15 }
16 service = new RecentVideosRecommendationService(videosService)
17 })
18 it('should filter out the given UUID from the results', async (done) => {
19 const vids = [
20 { uuid: 'uuid1' },
21 { uuid: 'uuid2' }
22 ]
23 getVideosMock.mockReturnValueOnce(of({ videos: vids }))
24 const result = await service.getRecommendations({ uuid: 'uuid1' }).toPromise()
25 const uuids = result.map(v => v.uuid)
26 expect(uuids).toEqual(['uuid2'])
27 done()
28 })
29 it('should return 5 results when the given UUID is NOT in the first 5 results', async (done) => {
30 const vids = [
31 { uuid: 'uuid2' },
32 { uuid: 'uuid3' },
33 { uuid: 'uuid4' },
34 { uuid: 'uuid5' },
35 { uuid: 'uuid6' },
36 { uuid: 'uuid7' }
37 ]
38 getVideosMock.mockReturnValueOnce(of({ videos: vids }))
39 const result = await service.getRecommendations({ uuid: 'uuid1' }).toPromise()
40 expect(result.length).toEqual(5)
41 done()
42 })
43 it('should return 5 results when the given UUID IS PRESENT in the first 5 results', async (done) => {
44 const vids = [
45 { uuid: 'uuid1' },
46 { uuid: 'uuid2' },
47 { uuid: 'uuid3' },
48 { uuid: 'uuid4' },
49 { uuid: 'uuid5' },
50 { uuid: 'uuid6' }
51 ]
52 getVideosMock
53 .mockReturnValueOnce(of({ videos: vids }))
54 const result = await service.getRecommendations({ uuid: 'uuid1' }).toPromise()
55 expect(result.length).toEqual(5)
56 done()
57 })
58 it('should fetch an extra result in case the given UUID is in the list', async (done) => {
59 await service.getRecommendations({ uuid: 'uuid1' }).toPromise()
60 let expectedSize = service.pageSize + 1
61 let params = { currentPage: jasmine.anything(), itemsPerPage: expectedSize }
62 expect(getVideosMock).toHaveBeenCalledWith(params, jasmine.anything())
63 done()
64 })
65 })
66})
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.html b/client/src/app/videos/recommendations/recommended-videos.component.html
index 73f9f0fe1..1fb89f8b0 100644
--- a/client/src/app/videos/recommendations/recommended-videos.component.html
+++ b/client/src/app/videos/recommendations/recommended-videos.component.html
@@ -4,6 +4,6 @@
4 </div> 4 </div>
5 5
6 <div *ngFor="let video of (videos$ | async)"> 6 <div *ngFor="let video of (videos$ | async)">
7 <my-video-miniature [video]="video" [user]="user"></my-video-miniature> 7 <my-video-miniature [video]="video" [user]="user" (videoBlacklisted)="onVideoRemoved()" (videoRemoved)="onVideoRemoved()"></my-video-miniature>
8 </div> 8 </div>
9</div> 9</div>
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.ts b/client/src/app/videos/recommendations/recommended-videos.component.ts
index c6c1d9e5d..68fd750cc 100644
--- a/client/src/app/videos/recommendations/recommended-videos.component.ts
+++ b/client/src/app/videos/recommendations/recommended-videos.component.ts
@@ -29,4 +29,7 @@ export class RecommendedVideosComponent implements OnChanges {
29 } 29 }
30 } 30 }
31 31
32 onVideoRemoved () {
33 this.store.requestNewRecommendations(this.inputRecommendation)
34 }
32} 35}
diff --git a/client/src/app/videos/recommendations/recommended-videos.store.spec.ts b/client/src/app/videos/recommendations/recommended-videos.store.spec.ts
deleted file mode 100644
index e12a3f520..000000000
--- a/client/src/app/videos/recommendations/recommended-videos.store.spec.ts
+++ /dev/null
@@ -1,22 +0,0 @@
1import { RecommendedVideosStore } from '@app/videos/recommendations/recommended-videos.store'
2import { RecommendationService } from '@app/videos/recommendations/recommendations.service'
3
4describe('RecommendedVideosStore', () => {
5 describe('requestNewRecommendations', () => {
6 let store: RecommendedVideosStore
7 let service: RecommendationService
8 beforeEach(() => {
9 service = {
10 getRecommendations: jest.fn(() => new Promise((r) => r()))
11 }
12 store = new RecommendedVideosStore(service)
13 })
14 it('should pull new videos from the service one time when given the same UUID twice', () => {
15 store.requestNewRecommendations('some-uuid')
16 store.requestNewRecommendations('some-uuid')
17 // Requests aren't fulfilled until someone asks for them (ie: subscribes)
18 store.recommendations$.subscribe()
19 expect(service.getRecommendations).toHaveBeenCalledTimes(1)
20 })
21 })
22})
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 c0be4b885..13d4023c2 100644
--- a/client/src/app/videos/video-list/video-local.component.ts
+++ b/client/src/app/videos/video-list/video-local.component.ts
@@ -1,7 +1,6 @@
1import { Component, OnDestroy, OnInit } from '@angular/core' 1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { immutableAssign } from '@app/shared/misc/utils' 3import { immutableAssign } from '@app/shared/misc/utils'
4import { Location } from '@angular/common'
5import { AuthService } from '../../core/auth' 4import { AuthService } from '../../core/auth'
6import { AbstractVideoList } from '../../shared/video/abstract-video-list' 5import { AbstractVideoList } from '../../shared/video/abstract-video-list'
7import { VideoSortField } from '../../shared/video/sort-field.type' 6import { VideoSortField } from '../../shared/video/sort-field.type'
@@ -10,7 +9,7 @@ import { VideoFilter } from '../../../../../shared/models/videos/video-query.typ
10import { I18n } from '@ngx-translate/i18n-polyfill' 9import { I18n } from '@ngx-translate/i18n-polyfill'
11import { ScreenService } from '@app/shared/misc/screen.service' 10import { ScreenService } from '@app/shared/misc/screen.service'
12import { UserRight } from '../../../../../shared/models/users' 11import { UserRight } from '../../../../../shared/models/users'
13import { Notifier } from '@app/core' 12import { Notifier, ServerService } from '@app/core'
14 13
15@Component({ 14@Component({
16 selector: 'my-videos-local', 15 selector: 'my-videos-local',
@@ -19,18 +18,17 @@ import { Notifier } from '@app/core'
19}) 18})
20export class VideoLocalComponent extends AbstractVideoList implements OnInit, OnDestroy { 19export class VideoLocalComponent extends AbstractVideoList implements OnInit, OnDestroy {
21 titlePage: string 20 titlePage: string
22 currentRoute = '/videos/local'
23 sort = '-publishedAt' as VideoSortField 21 sort = '-publishedAt' as VideoSortField
24 filter: VideoFilter = 'local' 22 filter: VideoFilter = 'local'
25 23
26 constructor ( 24 constructor (
27 protected router: Router, 25 protected router: Router,
26 protected serverService: ServerService,
28 protected route: ActivatedRoute, 27 protected route: ActivatedRoute,
29 protected notifier: Notifier, 28 protected notifier: Notifier,
30 protected authService: AuthService, 29 protected authService: AuthService,
31 protected location: Location,
32 protected i18n: I18n,
33 protected screenService: ScreenService, 30 protected screenService: ScreenService,
31 private i18n: I18n,
34 private videoService: VideoService 32 private videoService: VideoService
35 ) { 33 ) {
36 super() 34 super()
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 cb26592e3..b644dd798 100644
--- a/client/src/app/videos/video-list/video-overview.component.html
+++ b/client/src/app/videos/video-list/video-overview.component.html
@@ -7,7 +7,7 @@
7 <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a> 7 <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a>
8 </div> 8 </div>
9 9
10 <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user"></my-video-miniature> 10 <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user" [displayVideoActions]="false"></my-video-miniature>
11 </div> 11 </div>
12 12
13 <div class="section" *ngFor="let object of overview.tags"> 13 <div class="section" *ngFor="let object of overview.tags">
@@ -15,7 +15,7 @@
15 <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a> 15 <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a>
16 </div> 16 </div>
17 17
18 <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user"></my-video-miniature> 18 <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user" [displayVideoActions]="false"></my-video-miniature>
19 </div> 19 </div>
20 20
21 <div class="section channel" *ngFor="let object of overview.channels"> 21 <div class="section channel" *ngFor="let object of overview.channels">
@@ -27,7 +27,7 @@
27 </a> 27 </a>
28 </div> 28 </div>
29 29
30 <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user"></my-video-miniature> 30 <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user" [displayVideoActions]="false"></my-video-miniature>
31 </div> 31 </div>
32 32
33</div> 33</div>
diff --git a/client/src/app/videos/video-list/video-overview.component.scss b/client/src/app/videos/video-list/video-overview.component.scss
index aff45c072..a24766783 100644
--- a/client/src/app/videos/video-list/video-overview.component.scss
+++ b/client/src/app/videos/video-list/video-overview.component.scss
@@ -1,7 +1,10 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3@import '_miniature';
3 4
4.section { 5.section {
6 max-height: 500px; // 2 rows max
7 overflow: hidden;
5 padding-top: 10px; 8 padding-top: 10px;
6 9
7 &:first-child { 10 &:first-child {
@@ -43,11 +46,18 @@
43} 46}
44 47
45@media screen and (max-width: 500px) { 48@media screen and (max-width: 500px) {
49 .margin-content {
50 margin: 0 !important;
51 }
52
46 .section-title { 53 .section-title {
47 font-size: 17px; 54 font-size: 17px;
48 } 55 }
49 56
50 .section { 57 .section {
58 max-height: initial;
59 overflow: initial;
60
51 @include video-miniature-small-screen; 61 @include video-miniature-small-screen;
52 } 62 }
53} \ No newline at end of file 63}
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 f99c8abb6..80cef813e 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
@@ -1,6 +1,5 @@
1import { Component, OnDestroy, OnInit } from '@angular/core' 1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { Location } from '@angular/common'
4import { immutableAssign } from '@app/shared/misc/utils' 3import { immutableAssign } from '@app/shared/misc/utils'
5import { AuthService } from '../../core/auth' 4import { AuthService } from '../../core/auth'
6import { AbstractVideoList } from '../../shared/video/abstract-video-list' 5import { AbstractVideoList } from '../../shared/video/abstract-video-list'
@@ -8,7 +7,7 @@ import { VideoSortField } from '../../shared/video/sort-field.type'
8import { VideoService } from '../../shared/video/video.service' 7import { VideoService } from '../../shared/video/video.service'
9import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
10import { ScreenService } from '@app/shared/misc/screen.service' 9import { ScreenService } from '@app/shared/misc/screen.service'
11import { Notifier } from '@app/core' 10import { Notifier, ServerService } from '@app/core'
12 11
13@Component({ 12@Component({
14 selector: 'my-videos-recently-added', 13 selector: 'my-videos-recently-added',
@@ -17,17 +16,16 @@ import { Notifier } from '@app/core'
17}) 16})
18export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy { 17export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy {
19 titlePage: string 18 titlePage: string
20 currentRoute = '/videos/recently-added'
21 sort: VideoSortField = '-publishedAt' 19 sort: VideoSortField = '-publishedAt'
22 20
23 constructor ( 21 constructor (
24 protected router: Router,
25 protected route: ActivatedRoute, 22 protected route: ActivatedRoute,
26 protected location: Location, 23 protected serverService: ServerService,
24 protected router: Router,
27 protected notifier: Notifier, 25 protected notifier: Notifier,
28 protected authService: AuthService, 26 protected authService: AuthService,
29 protected i18n: I18n,
30 protected screenService: ScreenService, 27 protected screenService: ScreenService,
28 private i18n: I18n,
31 private videoService: VideoService 29 private videoService: VideoService
32 ) { 30 ) {
33 super() 31 super()
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 6fd74e67a..e2ad95bc4 100644
--- a/client/src/app/videos/video-list/video-trending.component.ts
+++ b/client/src/app/videos/video-list/video-trending.component.ts
@@ -1,6 +1,5 @@
1import { Component, OnDestroy, OnInit } from '@angular/core' 1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { Location } from '@angular/common'
4import { immutableAssign } from '@app/shared/misc/utils' 3import { immutableAssign } from '@app/shared/misc/utils'
5import { AuthService } from '../../core/auth' 4import { AuthService } from '../../core/auth'
6import { AbstractVideoList } from '../../shared/video/abstract-video-list' 5import { AbstractVideoList } from '../../shared/video/abstract-video-list'
@@ -17,18 +16,16 @@ import { Notifier, ServerService } from '@app/core'
17}) 16})
18export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy { 17export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy {
19 titlePage: string 18 titlePage: string
20 currentRoute = '/videos/trending'
21 defaultSort: VideoSortField = '-trending' 19 defaultSort: VideoSortField = '-trending'
22 20
23 constructor ( 21 constructor (
24 protected router: Router, 22 protected router: Router,
23 protected serverService: ServerService,
25 protected route: ActivatedRoute, 24 protected route: ActivatedRoute,
26 protected notifier: Notifier, 25 protected notifier: Notifier,
27 protected authService: AuthService, 26 protected authService: AuthService,
28 protected location: Location,
29 protected screenService: ScreenService, 27 protected screenService: ScreenService,
30 private serverService: ServerService, 28 private i18n: I18n,
31 protected i18n: I18n,
32 private videoService: VideoService 29 private videoService: VideoService
33 ) { 30 ) {
34 super() 31 super()
@@ -45,11 +42,11 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
45 42
46 if (trendingDays === 1) { 43 if (trendingDays === 1) {
47 this.titlePage = this.i18n('Trending for the last 24 hours') 44 this.titlePage = this.i18n('Trending for the last 24 hours')
48 this.titleTooltip = this.i18n('Trending videos are those totalizing the greatest number of views during the last 24 hours.') 45 this.titleTooltip = this.i18n('Trending videos are those totalizing the greatest number of views during the last 24 hours')
49 } else { 46 } else {
50 this.titlePage = this.i18n('Trending for the last {{days}} days', { days: trendingDays }) 47 this.titlePage = this.i18n('Trending for the last {{days}} days', { days: trendingDays })
51 this.titleTooltip = this.i18n( 48 this.titleTooltip = this.i18n(
52 'Trending videos are those totalizing the greatest number of views during the last {{days}} days.', 49 'Trending videos are those totalizing the greatest number of views during the last {{days}} days',
53 { days: trendingDays } 50 { days: trendingDays }
54 ) 51 )
55 } 52 }
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 bee828e12..2f0685ccc 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
@@ -1,7 +1,6 @@
1import { Component, OnDestroy, OnInit } from '@angular/core' 1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { immutableAssign } from '@app/shared/misc/utils' 3import { immutableAssign } from '@app/shared/misc/utils'
4import { Location } from '@angular/common'
5import { AuthService } from '../../core/auth' 4import { AuthService } from '../../core/auth'
6import { AbstractVideoList } from '../../shared/video/abstract-video-list' 5import { AbstractVideoList } from '../../shared/video/abstract-video-list'
7import { VideoSortField } from '../../shared/video/sort-field.type' 6import { VideoSortField } from '../../shared/video/sort-field.type'
@@ -9,7 +8,7 @@ import { VideoService } from '../../shared/video/video.service'
9import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
10import { ScreenService } from '@app/shared/misc/screen.service' 9import { ScreenService } from '@app/shared/misc/screen.service'
11import { OwnerDisplayType } from '@app/shared/video/video-miniature.component' 10import { OwnerDisplayType } from '@app/shared/video/video-miniature.component'
12import { Notifier } from '@app/core' 11import { Notifier, ServerService } from '@app/core'
13 12
14@Component({ 13@Component({
15 selector: 'my-videos-user-subscriptions', 14 selector: 'my-videos-user-subscriptions',
@@ -18,18 +17,17 @@ import { Notifier } from '@app/core'
18}) 17})
19export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy { 18export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy {
20 titlePage: string 19 titlePage: string
21 currentRoute = '/videos/subscriptions'
22 sort = '-publishedAt' as VideoSortField 20 sort = '-publishedAt' as VideoSortField
23 ownerDisplayType: OwnerDisplayType = 'auto' 21 ownerDisplayType: OwnerDisplayType = 'auto'
24 22
25 constructor ( 23 constructor (
26 protected router: Router, 24 protected router: Router,
25 protected serverService: ServerService,
27 protected route: ActivatedRoute, 26 protected route: ActivatedRoute,
28 protected notifier: Notifier, 27 protected notifier: Notifier,
29 protected authService: AuthService, 28 protected authService: AuthService,
30 protected location: Location,
31 protected i18n: I18n,
32 protected screenService: ScreenService, 29 protected screenService: ScreenService,
30 private i18n: I18n,
33 private videoService: VideoService 31 private videoService: VideoService
34 ) { 32 ) {
35 super() 33 super()
diff --git a/client/src/app/videos/videos-routing.module.ts b/client/src/app/videos/videos-routing.module.ts
index 58988ffd1..505173a5b 100644
--- a/client/src/app/videos/videos-routing.module.ts
+++ b/client/src/app/videos/videos-routing.module.ts
@@ -29,6 +29,10 @@ const videosRoutes: Routes = [
29 data: { 29 data: {
30 meta: { 30 meta: {
31 title: 'Trending videos' 31 title: 'Trending videos'
32 },
33 reuse: {
34 enabled: true,
35 key: 'trending-videos-list'
32 } 36 }
33 } 37 }
34 }, 38 },
@@ -38,6 +42,10 @@ const videosRoutes: Routes = [
38 data: { 42 data: {
39 meta: { 43 meta: {
40 title: 'Recently added videos' 44 title: 'Recently added videos'
45 },
46 reuse: {
47 enabled: true,
48 key: 'recently-added-videos-list'
41 } 49 }
42 } 50 }
43 }, 51 },
@@ -47,6 +55,10 @@ const videosRoutes: Routes = [
47 data: { 55 data: {
48 meta: { 56 meta: {
49 title: 'Subscriptions' 57 title: 'Subscriptions'
58 },
59 reuse: {
60 enabled: true,
61 key: 'subscription-videos-list'
50 } 62 }
51 } 63 }
52 }, 64 },
@@ -56,6 +68,10 @@ const videosRoutes: Routes = [
56 data: { 68 data: {
57 meta: { 69 meta: {
58 title: 'Local videos' 70 title: 'Local videos'
71 },
72 reuse: {
73 enabled: true,
74 key: 'local-videos-list'
59 } 75 }
60 } 76 }
61 }, 77 },
@@ -78,11 +94,7 @@ const videosRoutes: Routes = [
78 } 94 }
79 }, 95 },
80 { 96 {
81 path: 'watch/:uuid/comments/:commentId', 97 path: 'watch',
82 redirectTo: 'watch/:uuid'
83 },
84 {
85 path: 'watch/:uuid',
86 loadChildren: 'app/videos/+video-watch/video-watch.module#VideoWatchModule', 98 loadChildren: 'app/videos/+video-watch/video-watch.module#VideoWatchModule',
87 data: { 99 data: {
88 preload: 3000 100 preload: 3000