aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/shared')
-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.html9
-rw-r--r--client/src/app/shared/images/image-upload.component.scss18
-rw-r--r--client/src/app/shared/images/image-upload.component.ts69
-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.html38
-rw-r--r--client/src/app/shared/video/modals/video-blacklist.component.scss6
-rw-r--r--client/src/app/shared/video/modals/video-blacklist.component.ts76
-rw-r--r--client/src/app/shared/video/modals/video-download.component.html52
-rw-r--r--client/src/app/shared/video/modals/video-download.component.scss25
-rw-r--r--client/src/app/shared/video/modals/video-download.component.ts69
-rw-r--r--client/src/app/shared/video/modals/video-report.component.html36
-rw-r--r--client/src/app/shared/video/modals/video-report.component.scss10
-rw-r--r--client/src/app/shared/video/modals/video-report.component.ts81
-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
94 files changed, 3245 insertions, 488 deletions
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/shared/images/image-upload.component.html b/client/src/app/shared/images/image-upload.component.html
new file mode 100644
index 000000000..c09c862c4
--- /dev/null
+++ b/client/src/app/shared/images/image-upload.component.html
@@ -0,0 +1,9 @@
1<div class="root">
2 <my-reactive-file
3 [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize"
4 (fileChanged)="onFileChanged($event)"
5 ></my-reactive-file>
6
7 <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
8 <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
9</div>
diff --git a/client/src/app/shared/images/image-upload.component.scss b/client/src/app/shared/images/image-upload.component.scss
new file mode 100644
index 000000000..b63963bca
--- /dev/null
+++ b/client/src/app/shared/images/image-upload.component.scss
@@ -0,0 +1,18 @@
1@import '_variables';
2@import '_mixins';
3
4.root {
5 height: auto;
6 display: flex;
7 align-items: center;
8
9 .preview {
10 border: 2px solid grey;
11 border-radius: 4px;
12 margin-left: 50px;
13
14 &.no-image {
15 background-color: #ececec;
16 }
17 }
18}
diff --git a/client/src/app/shared/images/image-upload.component.ts b/client/src/app/shared/images/image-upload.component.ts
new file mode 100644
index 000000000..2da1592ff
--- /dev/null
+++ b/client/src/app/shared/images/image-upload.component.ts
@@ -0,0 +1,69 @@
1import { Component, forwardRef, Input } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
4import { ServerService } from '@app/core'
5
6@Component({
7 selector: 'my-image-upload',
8 styleUrls: [ './image-upload.component.scss' ],
9 templateUrl: './image-upload.component.html',
10 providers: [
11 {
12 provide: NG_VALUE_ACCESSOR,
13 useExisting: forwardRef(() => ImageUploadComponent),
14 multi: true
15 }
16 ]
17})
18export class ImageUploadComponent implements ControlValueAccessor {
19 @Input() inputLabel: string
20 @Input() inputName: string
21 @Input() previewWidth: string
22 @Input() previewHeight: string
23
24 imageSrc: SafeResourceUrl
25
26 private file: File
27
28 constructor (
29 private sanitizer: DomSanitizer,
30 private serverService: ServerService
31 ) {}
32
33 get videoImageExtensions () {
34 return this.serverService.getConfig().video.image.extensions
35 }
36
37 get maxVideoImageSize () {
38 return this.serverService.getConfig().video.image.size.max
39 }
40
41 onFileChanged (file: File) {
42 this.file = file
43
44 this.propagateChange(this.file)
45 this.updatePreview()
46 }
47
48 propagateChange = (_: any) => { /* empty */ }
49
50 writeValue (file: any) {
51 this.file = file
52 this.updatePreview()
53 }
54
55 registerOnChange (fn: (_: any) => void) {
56 this.propagateChange = fn
57 }
58
59 registerOnTouched () {
60 // Unused
61 }
62
63 private updatePreview () {
64 if (this.file) {
65 const url = URL.createObjectURL(this.file)
66 this.imageSrc = this.sanitizer.bypassSecurityTrustResourceUrl(url)
67 }
68 }
69}
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/shared/video/modals/video-blacklist.component.html b/client/src/app/shared/video/modals/video-blacklist.component.html
new file mode 100644
index 000000000..1a87bdcd4
--- /dev/null
+++ b/client/src/app/shared/video/modals/video-blacklist.component.html
@@ -0,0 +1,38 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 i18n class="modal-title">Blacklist video</h4>
4 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
5 </div>
6
7 <div class="modal-body">
8
9 <form novalidate [formGroup]="form" (ngSubmit)="blacklist()">
10 <div class="form-group">
11 <textarea i18n-placeholder placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }">
12 </textarea>
13 <div *ngIf="formErrors.reason" class="form-error">
14 {{ formErrors.reason }}
15 </div>
16 </div>
17
18 <div class="form-group" *ngIf="video.isLocal">
19 <my-peertube-checkbox
20 inputName="unfederate" formControlName="unfederate"
21 i18n-labelText labelText="Unfederate the video (ask for its deletion from the remote instances)"
22 ></my-peertube-checkbox>
23 </div>
24
25 <div class="form-group inputs">
26 <span i18n class="action-button action-button-cancel" (click)="hide()">
27 Cancel
28 </span>
29
30 <input
31 type="submit" i18n-value value="Submit" class="action-button-submit"
32 [disabled]="!form.valid"
33 >
34 </div>
35 </form>
36
37 </div>
38</ng-template>
diff --git a/client/src/app/shared/video/modals/video-blacklist.component.scss b/client/src/app/shared/video/modals/video-blacklist.component.scss
new file mode 100644
index 000000000..afcdb9a16
--- /dev/null
+++ b/client/src/app/shared/video/modals/video-blacklist.component.scss
@@ -0,0 +1,6 @@
1@import 'variables';
2@import 'mixins';
3
4textarea {
5 @include peertube-textarea(100%, 100px);
6}
diff --git a/client/src/app/shared/video/modals/video-blacklist.component.ts b/client/src/app/shared/video/modals/video-blacklist.component.ts
new file mode 100644
index 000000000..4e4e8dc50
--- /dev/null
+++ b/client/src/app/shared/video/modals/video-blacklist.component.ts
@@ -0,0 +1,76 @@
1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier, RedirectService } from '@app/core'
3import { VideoBlacklistService } from '../../../shared/video-blacklist'
4import { VideoDetails } from '../../../shared/video/video-details.model'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
9import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms'
10
11@Component({
12 selector: 'my-video-blacklist',
13 templateUrl: './video-blacklist.component.html',
14 styleUrls: [ './video-blacklist.component.scss' ]
15})
16export class VideoBlacklistComponent extends FormReactive implements OnInit {
17 @Input() video: VideoDetails = null
18
19 @ViewChild('modal') modal: NgbModal
20
21 @Output() videoBlacklisted = new EventEmitter()
22
23 error: string = null
24
25 private openedModal: NgbModalRef
26
27 constructor (
28 protected formValidatorService: FormValidatorService,
29 private modalService: NgbModal,
30 private videoBlacklistValidatorsService: VideoBlacklistValidatorsService,
31 private videoBlacklistService: VideoBlacklistService,
32 private notifier: Notifier,
33 private redirectService: RedirectService,
34 private i18n: I18n
35 ) {
36 super()
37 }
38
39 ngOnInit () {
40 const defaultValues = { unfederate: 'true' }
41
42 this.buildForm({
43 reason: this.videoBlacklistValidatorsService.VIDEO_BLACKLIST_REASON,
44 unfederate: null
45 }, defaultValues)
46 }
47
48 show () {
49 this.openedModal = this.modalService.open(this.modal, { keyboard: false })
50 }
51
52 hide () {
53 this.openedModal.close()
54 this.openedModal = null
55 }
56
57 blacklist () {
58 const reason = this.form.value[ 'reason' ] || undefined
59 const unfederate = this.video.isLocal ? this.form.value[ 'unfederate' ] : undefined
60
61 this.videoBlacklistService.blacklistVideo(this.video.id, reason, unfederate)
62 .subscribe(
63 () => {
64 this.notifier.success(this.i18n('Video blacklisted.'))
65 this.hide()
66
67 this.video.blacklisted = true
68 this.video.blacklistedReason = reason
69
70 this.videoBlacklisted.emit()
71 },
72
73 err => this.notifier.error(err.message)
74 )
75 }
76}
diff --git a/client/src/app/shared/video/modals/video-download.component.html b/client/src/app/shared/video/modals/video-download.component.html
new file mode 100644
index 000000000..dd01c1388
--- /dev/null
+++ b/client/src/app/shared/video/modals/video-download.component.html
@@ -0,0 +1,52 @@
1<ng-template #modal let-hide="close">
2 <div class="modal-header">
3 <h4 i18n class="modal-title">Download video</h4>
4 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
5 </div>
6
7 <div class="modal-body">
8 <div class="form-group">
9 <div class="input-group input-group-sm">
10 <div class="input-group-prepend peertube-select-container">
11 <select [(ngModel)]="resolutionId">
12 <option *ngFor="let file of video?.files" [value]="file.resolution.id">{{ file.resolution.label }}</option>
13 </select>
14 </div>
15 <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" />
16 <div class="input-group-append">
17 <button [ngxClipboard]="urlInput" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
18 <span class="glyphicon glyphicon-copy"></span>
19 </button>
20 </div>
21 </div>
22 </div>
23
24 <div class="download-type">
25 <div class="peertube-radio-container">
26 <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
27 <label i18n for="download-direct">Direct download</label>
28 </div>
29
30 <div class="peertube-radio-container">
31 <input type="radio" name="download" id="download-torrent" [(ngModel)]="downloadType" value="torrent">
32 <label i18n for="download-torrent">Torrent (.torrent file)</label>
33 </div>
34
35 <div class="peertube-radio-container">
36 <input type="radio" name="download" id="download-magnet" [(ngModel)]="downloadType" value="magnet">
37 <label i18n for="download-magnet">Torrent (magnet link)</label>
38 </div>
39 </div>
40 </div>
41
42 <div class="modal-footer inputs">
43 <span i18n class="action-button action-button-cancel" (click)="hide()">
44 Cancel
45 </span>
46
47 <input
48 type="submit" i18n-value value="Download" class="action-button-submit"
49 (click)="download()"
50 >
51 </div>
52</ng-template>
diff --git a/client/src/app/shared/video/modals/video-download.component.scss b/client/src/app/shared/video/modals/video-download.component.scss
new file mode 100644
index 000000000..3e826c3b6
--- /dev/null
+++ b/client/src/app/shared/video/modals/video-download.component.scss
@@ -0,0 +1,25 @@
1@import 'variables';
2@import 'mixins';
3
4.peertube-select-container {
5 @include peertube-select-container(100px);
6
7 border-top-right-radius: 0;
8 border-bottom-right-radius: 0;
9 border-right: none;
10
11 select {
12 height: inherit;
13 }
14}
15
16.download-type {
17 margin-top: 30px;
18
19 .peertube-radio-container {
20 @include peertube-radio-container;
21
22 display: inline-block;
23 margin-right: 30px;
24 }
25}
diff --git a/client/src/app/shared/video/modals/video-download.component.ts b/client/src/app/shared/video/modals/video-download.component.ts
new file mode 100644
index 000000000..d6d10d29e
--- /dev/null
+++ b/client/src/app/shared/video/modals/video-download.component.ts
@@ -0,0 +1,69 @@
1import { Component, ElementRef, ViewChild } from '@angular/core'
2import { VideoDetails } from '../../../shared/video/video-details.model'
3import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { Notifier } from '@app/core'
6
7@Component({
8 selector: 'my-video-download',
9 templateUrl: './video-download.component.html',
10 styleUrls: [ './video-download.component.scss' ]
11})
12export class VideoDownloadComponent {
13 @ViewChild('modal') modal: ElementRef
14
15 downloadType: 'direct' | 'torrent' | 'magnet' = 'torrent'
16 resolutionId: number | string = -1
17
18 video: VideoDetails
19
20 constructor (
21 private notifier: Notifier,
22 private modalService: NgbModal,
23 private i18n: I18n
24 ) { }
25
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
33 this.resolutionId = this.video.files[0].resolution.id
34 }
35
36 onClose () {
37 this.video = undefined
38 }
39
40 download () {
41 window.location.assign(this.getLink())
42 }
43
44 getLink () {
45 // HTML select send us a string, so convert it to a number
46 this.resolutionId = parseInt(this.resolutionId.toString(), 10)
47
48 const file = this.video.files.find(f => f.resolution.id === this.resolutionId)
49 if (!file) {
50 console.error('Could not find file with resolution %d.', this.resolutionId)
51 return
52 }
53
54 switch (this.downloadType) {
55 case 'direct':
56 return file.fileDownloadUrl
57
58 case 'torrent':
59 return file.torrentDownloadUrl
60
61 case 'magnet':
62 return file.magnetUri
63 }
64 }
65
66 activateCopiedMessage () {
67 this.notifier.success(this.i18n('Copied'))
68 }
69}
diff --git a/client/src/app/shared/video/modals/video-report.component.html b/client/src/app/shared/video/modals/video-report.component.html
new file mode 100644
index 000000000..b9434da26
--- /dev/null
+++ b/client/src/app/shared/video/modals/video-report.component.html
@@ -0,0 +1,36 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 i18n class="modal-title">Report video</h4>
4 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
5 </div>
6
7 <div class="modal-body">
8
9 <div i18n class="information">
10 Your report will be sent to moderators of {{ currentHost }}.
11 <ng-container *ngIf="isRemoteVideo()"> It will be forwarded to origin instance {{ originHost }} too.</ng-container>
12 </div>
13
14 <form novalidate [formGroup]="form" (ngSubmit)="report()">
15 <div class="form-group">
16 <textarea i18n-placeholder placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }">
17 </textarea>
18 <div *ngIf="formErrors.reason" class="form-error">
19 {{ formErrors.reason }}
20 </div>
21 </div>
22
23 <div class="form-group inputs">
24 <span i18n class="action-button action-button-cancel" (click)="hide()">
25 Cancel
26 </span>
27
28 <input
29 type="submit" i18n-value value="Submit" class="action-button-submit"
30 [disabled]="!form.valid"
31 >
32 </div>
33 </form>
34
35 </div>
36</ng-template>
diff --git a/client/src/app/shared/video/modals/video-report.component.scss b/client/src/app/shared/video/modals/video-report.component.scss
new file mode 100644
index 000000000..4713660a2
--- /dev/null
+++ b/client/src/app/shared/video/modals/video-report.component.scss
@@ -0,0 +1,10 @@
1@import 'variables';
2@import 'mixins';
3
4.information {
5 margin-bottom: 20px;
6}
7
8textarea {
9 @include peertube-textarea(100%, 100px);
10}
diff --git a/client/src/app/shared/video/modals/video-report.component.ts b/client/src/app/shared/video/modals/video-report.component.ts
new file mode 100644
index 000000000..725dd020f
--- /dev/null
+++ b/client/src/app/shared/video/modals/video-report.component.ts
@@ -0,0 +1,81 @@
1import { Component, Input, OnInit, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core'
3import { FormReactive } from '../../../shared/forms'
4import { VideoDetails } from '../../../shared/video/video-details.model'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service'
8import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
9import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
10import { VideoAbuseService } from '@app/shared/video-abuse'
11
12@Component({
13 selector: 'my-video-report',
14 templateUrl: './video-report.component.html',
15 styleUrls: [ './video-report.component.scss' ]
16})
17export class VideoReportComponent extends FormReactive implements OnInit {
18 @Input() video: VideoDetails = null
19
20 @ViewChild('modal') modal: NgbModal
21
22 error: string = null
23
24 private openedModal: NgbModalRef
25
26 constructor (
27 protected formValidatorService: FormValidatorService,
28 private modalService: NgbModal,
29 private videoAbuseValidatorsService: VideoAbuseValidatorsService,
30 private videoAbuseService: VideoAbuseService,
31 private notifier: Notifier,
32 private i18n: I18n
33 ) {
34 super()
35 }
36
37 get currentHost () {
38 return window.location.host
39 }
40
41 get originHost () {
42 if (this.isRemoteVideo()) {
43 return this.video.account.host
44 }
45
46 return ''
47 }
48
49 ngOnInit () {
50 this.buildForm({
51 reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON
52 })
53 }
54
55 show () {
56 this.openedModal = this.modalService.open(this.modal, { keyboard: false })
57 }
58
59 hide () {
60 this.openedModal.close()
61 this.openedModal = null
62 }
63
64 report () {
65 const reason = this.form.value['reason']
66
67 this.videoAbuseService.reportVideo(this.video.id, reason)
68 .subscribe(
69 () => {
70 this.notifier.success(this.i18n('Video reported.'))
71 this.hide()
72 },
73
74 err => this.notifier.error(err.message)
75 )
76 }
77
78 isRemoteVideo () {
79 return !this.video.isLocal
80 }
81}
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}