diff options
Diffstat (limited to 'client/src/app/shared')
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 @@ | |||
1 | import { Directive, Input, TemplateRef } from '@angular/core' | ||
2 | |||
3 | @Directive({ | ||
4 | selector: '[ptTemplate]' | ||
5 | }) | ||
6 | export 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 @@ | |||
1 | import { Component, Input } from '@angular/core' | 1 | import { Component, Input } from '@angular/core' |
2 | import { GlobalIconName } from '@app/shared/images/global-icon.component' | ||
2 | 3 | ||
3 | export type DropdownAction<T> = { | 4 | export 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 | ||
12 | export type DropdownButtonSize = 'normal' | 'small' | ||
13 | export type DropdownTheme = 'orange' | 'grey' | ||
14 | export 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> = { | |||
16 | export class ActionDropdownComponent<T> { | 22 | export 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 @@ | |||
1 | import { Component, Input } from '@angular/core' | 1 | import { Component, Input } from '@angular/core' |
2 | import { GlobalIconName } from '@app/shared/icons/global-icon.component' | 2 | import { 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' | |||
10 | export * from './video-channel-validators.service' | 10 | export * from './video-channel-validators.service' |
11 | export * from './video-comment-validators.service' | 11 | export * from './video-comment-validators.service' |
12 | export * from './video-validators.service' | 12 | export * from './video-validators.service' |
13 | export * from './video-playlist-validators.service' | ||
13 | export * from './video-captions-validators.service' | 14 | export * from './video-captions-validators.service' |
14 | export * from './video-change-ownership-validators.service' | 15 | export * from './video-change-ownership-validators.service' |
15 | export * from './video-accept-ownership-validators.service' | 16 | export * 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { AbstractControl, FormControl, Validators } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator } from '@app/shared' | ||
5 | import { VideoPlaylistPrivacy } from '@shared/models' | ||
6 | |||
7 | @Injectable() | ||
8 | export 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 @@ | |||
1 | import { Component, forwardRef, Input } from '@angular/core' | 1 | import { ChangeDetectorRef, Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core' |
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | 2 | import { 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 @@ | |||
1 | p-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 @@ | |||
1 | import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core' | ||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
3 | import { 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 | }) | ||
17 | export 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 @@ | |||
1 | import { Component, ElementRef, Input, OnInit } from '@angular/core' | 1 | import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core' |
2 | 2 | ||
3 | const icons = { | 3 | const 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 | ||
31 | export type GlobalIconName = keyof typeof icons | 51 | export 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 | }) |
38 | export class GlobalIconComponent implements OnInit { | 59 | export 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 @@ | |||
1 | import { Component, forwardRef, Input } from '@angular/core' | ||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
3 | import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' | ||
4 | import { 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 | }) | ||
18 | export 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' | |||
3 | import { NavigationEnd, Router } from '@angular/router' | 3 | import { NavigationEnd, Router } from '@angular/router' |
4 | import { Subscription } from 'rxjs' | 4 | import { Subscription } from 'rxjs' |
5 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' | 5 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' |
6 | import { GlobalIconName } from '@app/shared/images/global-icon.component' | ||
7 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
6 | 8 | ||
7 | export type TopMenuDropdownParam = { | 9 | export 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 | |||
9 | export class LoaderComponent { | 8 | export 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 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | |||
3 | @Component({ | ||
4 | selector: 'my-small-loader', | ||
5 | styleUrls: [ ], | ||
6 | templateUrl: './small-loader.component.html' | ||
7 | }) | ||
8 | |||
9 | export 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 | ||
20 | function populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support: string }[]) { | 20 | function 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 |
80 | function objectToFormData (obj: any, form?: FormData, namespace?: string) { | 80 | function 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 | }) |
16 | export class UserModerationDropdownComponent implements OnChanges { | 15 | export 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 @@ | |||
1 | import { VideoChannelAttribute, VideoConstant, VideosOverview as VideosOverviewServer } from '../../../../../shared/models' | 1 | import { VideoChannelSummary, VideoConstant, VideosOverview as VideosOverviewServer } from '../../../../../shared/models' |
2 | import { Video } from '@app/shared/video/video.model' | 2 | import { Video } from '@app/shared/video/video.model' |
3 | 3 | ||
4 | export class VideosOverview implements VideosOverviewServer { | 4 | export 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 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { Injectable } from '@angular/core' |
2 | import { LinkifierService } from '@app/shared/renderer/linkifier.service' | 2 | import { LinkifierService } from '@app/shared/renderer/linkifier.service' |
3 | import * as sanitizeHtml from 'sanitize-html' | ||
4 | 3 | ||
5 | @Injectable() | 4 | @Injectable() |
6 | export class HtmlRendererService { | 5 | export 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 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { Injectable } from '@angular/core' |
2 | import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' | 2 | import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' |
3 | // FIXME: use @types/linkify when https://github.com/DefinitelyTyped/DefinitelyTyped/pull/29682/files is merged? | 3 | import * as linkify from 'linkifyjs' |
4 | const linkify = require('linkifyjs') | 4 | import linkifyHtml from 'linkifyjs/html' |
5 | const linkifyHtml = require('linkifyjs/html') | ||
6 | 5 | ||
7 | @Injectable() | 6 | @Injectable() |
8 | export class LinkifierService { | 7 | export 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 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { Injectable } from '@angular/core' |
2 | 2 | ||
3 | import * as MarkdownIt from 'markdown-it' | 3 | import { MarkdownIt } from 'markdown-it' |
4 | 4 | ||
5 | @Injectable() | 5 | @Injectable() |
6 | export class MarkdownService { | 6 | export 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' | |||
14 | import { ButtonComponent } from './buttons/button.component' | 14 | import { ButtonComponent } from './buttons/button.component' |
15 | import { DeleteButtonComponent } from './buttons/delete-button.component' | 15 | import { DeleteButtonComponent } from './buttons/delete-button.component' |
16 | import { EditButtonComponent } from './buttons/edit-button.component' | 16 | import { EditButtonComponent } from './buttons/edit-button.component' |
17 | import { FromNowPipe } from './misc/from-now.pipe' | ||
18 | import { LoaderComponent } from './misc/loader.component' | 17 | import { LoaderComponent } from './misc/loader.component' |
19 | import { NumberFormatterPipe } from './misc/number-formatter.pipe' | ||
20 | import { ObjectLengthPipe } from './misc/object-length.pipe' | ||
21 | import { RestExtractor, RestService } from './rest' | 18 | import { RestExtractor, RestService } from './rest' |
22 | import { UserService } from './users' | 19 | import { UserService } from './users' |
23 | import { VideoAbuseService } from './video-abuse' | 20 | import { 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' |
50 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' | 48 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' |
49 | import { InputMaskModule } from 'primeng/inputmask' | ||
51 | import { ScreenService } from '@app/shared/misc/screen.service' | 50 | import { ScreenService } from '@app/shared/misc/screen.service' |
52 | import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' | 51 | import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' |
53 | import { VideoCaptionService } from '@app/shared/video-caption' | 52 | import { VideoCaptionService } from '@app/shared/video-caption' |
@@ -68,7 +67,24 @@ import { UserNotificationsComponent } from '@app/shared/users/user-notifications | |||
68 | import { InstanceService } from '@app/shared/instance/instance.service' | 67 | import { InstanceService } from '@app/shared/instance/instance.service' |
69 | import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer' | 68 | import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer' |
70 | import { ConfirmComponent } from '@app/shared/confirm/confirm.component' | 69 | import { ConfirmComponent } from '@app/shared/confirm/confirm.component' |
71 | import { GlobalIconComponent } from '@app/shared/icons/global-icon.component' | 70 | import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component' |
71 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
72 | import { ImageUploadComponent } from '@app/shared/images/image-upload.component' | ||
73 | import { GlobalIconComponent } from '@app/shared/images/global-icon.component' | ||
74 | import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' | ||
75 | import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' | ||
76 | import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component' | ||
77 | import { VideoPlaylistElementMiniatureComponent } from '@app/shared/video-playlist/video-playlist-element-miniature.component' | ||
78 | import { VideosSelectionComponent } from '@app/shared/video/videos-selection.component' | ||
79 | import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe' | ||
80 | import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe' | ||
81 | import { FromNowPipe } from '@app/shared/angular/from-now.pipe' | ||
82 | import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' | ||
83 | import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component' | ||
84 | import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component' | ||
85 | import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component' | ||
86 | import { VideoReportComponent } from '@app/shared/video/modals/video-report.component' | ||
87 | import { 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 @@ | |||
1 | import { UserNotification as UserNotificationServer, UserNotificationType, VideoInfo, ActorInfo } from '../../../../../shared' | 1 | import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '../../../../../shared' |
2 | import { Actor } from '@app/shared/actor/actor.model' | 2 | import { Actor } from '@app/shared/actor/actor.model' |
3 | 3 | ||
4 | export class UserNotification implements UserNotificationServer { | 4 | export 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 | |||
7 | import { UserNotification } from './user-notification.model' | 7 | import { UserNotification } from './user-notification.model' |
8 | import { AuthService } from '../../core' | 8 | import { AuthService } from '../../core' |
9 | import { ComponentPagination } from '../rest/component-pagination.model' | 9 | import { ComponentPagination } from '../rest/component-pagination.model' |
10 | import { User } from '..' | 10 | import { User } from '../users/user.model' |
11 | import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' | 11 | import { 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 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' |
2 | import { UserNotificationService } from '@app/shared/users/user-notification.service' | 2 | import { UserNotificationService } from '@app/shared/users/user-notification.service' |
3 | import { UserNotificationType } from '../../../../../shared' | 3 | import { UserNotificationType } from '../../../../../shared' |
4 | import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model' | 4 | import { 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 | |||
2 | import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' | 2 | import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' |
3 | import { Account } from '@app/shared/account/account.model' | 3 | import { Account } from '@app/shared/account/account.model' |
4 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' | 4 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' |
5 | import { UserAdminFlag } from '@shared/models/users/user-flag.model' | ||
5 | 6 | ||
6 | export class User implements UserServerModel { | 7 | export 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 @@ | |||
1 | import { catchError, map } from 'rxjs/operators' | 1 | import { catchError, map, concatMap, toArray } from 'rxjs/operators' |
2 | import { HttpClient, HttpParams } from '@angular/common/http' | 2 | import { HttpClient, HttpParams } from '@angular/common/http' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { SortMeta } from 'primeng/components/common/sortmeta' | 4 | import { SortMeta } from 'primeng/components/common/sortmeta' |
5 | import { Observable } from 'rxjs' | 5 | import { from as observableFrom, Observable } from 'rxjs' |
6 | import { VideoBlacklist, ResultList } from '../../../../../shared' | 6 | import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared' |
7 | import { Video } from '../video/video.model' | ||
7 | import { environment } from '../../../environments/environment' | 8 | import { environment } from '../../../environments/environment' |
8 | import { RestExtractor, RestPagination, RestService } from '../rest' | 9 | import { RestExtractor, RestPagination, RestService } from '../rest' |
10 | import { ComponentPagination } from '../rest/component-pagination.model' | ||
9 | 11 | ||
10 | @Injectable() | 12 | @Injectable() |
11 | export class VideoBlacklistService { | 13 | export 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 | |||
98 | input[type=text] { | ||
99 | @include peertube-input-text(200px); | ||
100 | |||
101 | display: block; | ||
102 | } | ||
103 | |||
104 | input[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 @@ | |||
1 | import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core' | ||
2 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
3 | import { AuthService, Notifier } from '@app/core' | ||
4 | import { forkJoin } from 'rxjs' | ||
5 | import { Video, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models' | ||
6 | import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/forms' | ||
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
8 | import { secondsToTime } from '../../../assets/player/utils' | ||
9 | |||
10 | type 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 | }) | ||
25 | export 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 | |||
5 | my-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 @@ | |||
1 | import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core' | ||
2 | import { Video } from '@app/shared/video/video.model' | ||
3 | import { VideoPlaylistElementUpdate } from '@shared/models' | ||
4 | import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' | ||
5 | import { ActivatedRoute } from '@angular/router' | ||
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
7 | import { VideoService } from '@app/shared/video/video.service' | ||
8 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
9 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' | ||
10 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
11 | import { 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 | }) | ||
19 | export 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 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | import { 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 | }) | ||
9 | export 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 @@ | |||
1 | import { | ||
2 | VideoChannelSummary, | ||
3 | VideoConstant, | ||
4 | VideoPlaylist as ServerVideoPlaylist, | ||
5 | VideoPlaylistPrivacy, | ||
6 | VideoPlaylistType | ||
7 | } from '../../../../../shared/models/videos' | ||
8 | import { AccountSummary, peertubeTranslate } from '@shared/models' | ||
9 | import { Actor } from '@app/shared/actor/actor.model' | ||
10 | import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' | ||
11 | |||
12 | export 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 @@ | |||
1 | import { bufferTime, catchError, filter, first, map, share, switchMap } from 'rxjs/operators' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | import { Observable, ReplaySubject, Subject } from 'rxjs' | ||
4 | import { RestExtractor } from '../rest/rest-extractor.service' | ||
5 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
6 | import { ResultList, VideoPlaylistElementCreate, VideoPlaylistElementUpdate } from '../../../../../shared' | ||
7 | import { environment } from '../../../environments/environment' | ||
8 | import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model' | ||
9 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | ||
10 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | ||
11 | import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model' | ||
12 | import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model' | ||
13 | import { objectToFormData } from '@app/shared/misc/utils' | ||
14 | import { ServerService } from '@app/core' | ||
15 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
16 | import { AccountService } from '@app/shared/account/account.service' | ||
17 | import { Account } from '@app/shared/account/account.model' | ||
18 | import { RestService } from '@app/shared/rest' | ||
19 | import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model' | ||
20 | import { VideoPlaylistReorder } from '@shared/models/videos/playlist/video-playlist-reorder.model' | ||
21 | |||
22 | @Injectable() | ||
23 | export 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 @@ | |||
1 | import { debounceTime } from 'rxjs/operators' | 1 | import { debounceTime } from 'rxjs/operators' |
2 | import { ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core' | 2 | import { OnDestroy, OnInit } from '@angular/core' |
3 | import { ActivatedRoute, Router } from '@angular/router' | 3 | import { ActivatedRoute, Router } from '@angular/router' |
4 | import { Location } from '@angular/common' | ||
5 | import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' | ||
6 | import { fromEvent, Observable, Subscription } from 'rxjs' | 4 | import { fromEvent, Observable, Subscription } from 'rxjs' |
7 | import { AuthService } from '../../core/auth' | 5 | import { AuthService } from '../../core/auth' |
8 | import { ComponentPagination } from '../rest/component-pagination.model' | 6 | import { ComponentPagination } from '../rest/component-pagination.model' |
9 | import { VideoSortField } from './sort-field.type' | 7 | import { VideoSortField } from './sort-field.type' |
10 | import { Video } from './video.model' | 8 | import { Video } from './video.model' |
11 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
12 | import { ScreenService } from '@app/shared/misc/screen.service' | 9 | import { ScreenService } from '@app/shared/misc/screen.service' |
13 | import { OwnerDisplayType } from '@app/shared/video/video-miniature.component' | 10 | import { MiniatureDisplayOptions, OwnerDisplayType } from '@app/shared/video/video-miniature.component' |
14 | import { Syndication } from '@app/shared/video/syndication.model' | 11 | import { Syndication } from '@app/shared/video/syndication.model' |
15 | import { Notifier } from '@app/core' | 12 | import { Notifier, ServerService } from '@app/core' |
16 | 13 | import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' | |
17 | export 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 | ||
15 | export 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 @@ | |||
1 | import { distinct, distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' | 1 | import { distinct, distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' |
2 | import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' | 2 | import { Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' |
3 | import { fromEvent, Subscription } from 'rxjs' | 3 | import { fromEvent, Subscription } from 'rxjs' |
4 | 4 | ||
5 | @Directive({ | 5 | @Directive({ |
6 | selector: '[myInfiniteScroller]' | 6 | selector: '[myInfiniteScroller]' |
7 | }) | 7 | }) |
8 | export class InfiniteScrollerDirective implements OnInit, OnDestroy { | 8 | export 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 | |||
4 | textarea { | ||
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 @@ | |||
1 | import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | ||
2 | import { Notifier, RedirectService } from '@app/core' | ||
3 | import { VideoBlacklistService } from '../../../shared/video-blacklist' | ||
4 | import { VideoDetails } from '../../../shared/video/video-details.model' | ||
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | ||
7 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
8 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | ||
9 | import { 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 | }) | ||
16 | export 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 @@ | |||
1 | import { Component, ElementRef, ViewChild } from '@angular/core' | ||
2 | import { VideoDetails } from '../../../shared/video/video-details.model' | ||
3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
4 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
5 | import { 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 | }) | ||
12 | export 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 | |||
8 | textarea { | ||
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 @@ | |||
1 | import { Component, Input, OnInit, ViewChild } from '@angular/core' | ||
2 | import { Notifier } from '@app/core' | ||
3 | import { FormReactive } from '../../../shared/forms' | ||
4 | import { VideoDetails } from '../../../shared/video/video-details.model' | ||
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | ||
7 | import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' | ||
8 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
9 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | ||
10 | import { 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 | }) | ||
17 | export 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 @@ | |||
1 | import { AfterViewInit, Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' | ||
2 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
3 | import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component' | ||
4 | import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' | ||
5 | import { BlocklistService } from '@app/shared/blocklist' | ||
6 | import { Video } from '@app/shared/video/video.model' | ||
7 | import { VideoService } from '@app/shared/video/video.service' | ||
8 | import { VideoDetails } from '@app/shared/video/video-details.model' | ||
9 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' | ||
10 | import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' | ||
11 | import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component' | ||
12 | import { VideoReportComponent } from '@app/shared/video/modals/video-report.component' | ||
13 | import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component' | ||
14 | import { VideoBlacklistService } from '@app/shared/video-blacklist' | ||
15 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
16 | |||
17 | export 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 | }) | ||
31 | export 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' | |||
3 | import { Video } from '../../shared/video/video.model' | 3 | import { Video } from '../../shared/video/video.model' |
4 | import { Account } from '@app/shared/account/account.model' | 4 | import { Account } from '@app/shared/account/account.model' |
5 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 5 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
6 | import { VideoStreamingPlaylist } from '../../../../../shared/models/videos/video-streaming-playlist.model' | ||
7 | import { VideoStreamingPlaylistType } from '../../../../../shared/models/videos/video-streaming-playlist.type' | ||
6 | 8 | ||
7 | export class VideoDetails extends Video implements VideoDetailsServerModel { | 9 | export 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 @@ | |||
1 | import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core' | 1 | import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, LOCALE_ID, OnInit, Output } from '@angular/core' |
2 | import { User } from '../users' | 2 | import { User } from '../users' |
3 | import { Video } from './video.model' | 3 | import { Video } from './video.model' |
4 | import { ServerService } from '@app/core' | 4 | import { ServerService } from '@app/core' |
5 | import { VideoPrivacy } from '../../../../../shared' | 5 | import { VideoPrivacy, VideoState } from '../../../../../shared' |
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
7 | import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component' | ||
8 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
6 | 9 | ||
7 | export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' | 10 | export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' |
11 | export 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' | |||
15 | export class VideoMiniatureComponent implements OnInit { | 28 | export 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' | |||
10 | export class VideoThumbnailComponent { | 10 | export 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 @@ | |||
1 | import { User } from '../' | 1 | import { User } from '../' |
2 | import { Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared' | 2 | import { PlaylistElement, UserRight, Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared' |
3 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' | 3 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' |
4 | import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model' | 4 | import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model' |
5 | import { durationToString, getAbsoluteAPIUrl } from '../misc/utils' | 5 | import { durationToString, getAbsoluteAPIUrl } from '../misc/utils' |
6 | import { peertubeTranslate, ServerConfig } from '../../../../../shared/models' | 6 | import { peertubeTranslate, ServerConfig } from '../../../../../shared/models' |
7 | import { Actor } from '@app/shared/actor/actor.model' | 7 | import { Actor } from '@app/shared/actor/actor.model' |
8 | import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model' | 8 | import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model' |
9 | import { AuthUser } from '@app/core' | ||
9 | 10 | ||
10 | export class Video implements VideoServerModel { | 11 | export 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' | |||
31 | import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' | 31 | import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' |
32 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 32 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
33 | import { I18n } from '@ngx-translate/i18n-polyfill' | 33 | import { I18n } from '@ngx-translate/i18n-polyfill' |
34 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
35 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
34 | 36 | ||
35 | export interface VideosProvider { | 37 | export 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 @@ | |||
1 | import { | ||
2 | AfterContentInit, | ||
3 | Component, | ||
4 | ContentChildren, | ||
5 | EventEmitter, | ||
6 | Input, | ||
7 | OnDestroy, | ||
8 | OnInit, | ||
9 | Output, | ||
10 | QueryList, | ||
11 | TemplateRef | ||
12 | } from '@angular/core' | ||
13 | import { ActivatedRoute, Router } from '@angular/router' | ||
14 | import { AbstractVideoList } from '@app/shared/video/abstract-video-list' | ||
15 | import { AuthService, Notifier, ServerService } from '@app/core' | ||
16 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
17 | import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component' | ||
18 | import { Observable } from 'rxjs' | ||
19 | import { Video } from '@app/shared/video/video.model' | ||
20 | import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' | ||
21 | import { VideoSortField } from '@app/shared/video/sort-field.type' | ||
22 | |||
23 | export 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 | }) | ||
30 | export 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 | } | ||