aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/shared')
-rw-r--r--client/src/app/shared/angular/from-now.pipe.ts7
-rw-r--r--client/src/app/shared/angular/highlight.pipe.ts54
-rw-r--r--client/src/app/shared/angular/timestamp-route-transformer.directive.ts51
-rw-r--r--client/src/app/shared/angular/video-duration-formatter.pipe.ts21
-rw-r--r--client/src/app/shared/blocklist/blocklist.service.ts12
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.html18
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.scss4
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.ts8
-rw-r--r--client/src/app/shared/buttons/button.component.scss23
-rw-r--r--client/src/app/shared/buttons/edit-button.component.ts2
-rw-r--r--client/src/app/shared/confirm/confirm.component.html8
-rw-r--r--client/src/app/shared/confirm/confirm.component.ts3
-rw-r--r--client/src/app/shared/forms/form-validators/custom-config-validators.service.ts2
-rw-r--r--client/src/app/shared/forms/form-validators/user-validators.service.ts12
-rw-r--r--client/src/app/shared/forms/input-readonly-copy.component.html4
-rw-r--r--client/src/app/shared/forms/markdown-textarea.component.html41
-rw-r--r--client/src/app/shared/forms/markdown-textarea.component.scss246
-rw-r--r--client/src/app/shared/forms/markdown-textarea.component.ts39
-rw-r--r--client/src/app/shared/forms/peertube-checkbox.component.html2
-rw-r--r--client/src/app/shared/forms/peertube-checkbox.component.scss15
-rw-r--r--client/src/app/shared/forms/peertube-checkbox.component.ts1
-rw-r--r--client/src/app/shared/forms/timestamp-input.component.scss7
-rw-r--r--client/src/app/shared/images/global-icon.component.ts105
-rw-r--r--client/src/app/shared/images/preview-upload.component.html6
-rw-r--r--client/src/app/shared/images/preview-upload.component.scss6
-rw-r--r--client/src/app/shared/images/preview-upload.component.ts20
-rw-r--r--client/src/app/shared/instance/instance-features-table.component.html15
-rw-r--r--client/src/app/shared/instance/instance-statistics.component.ts23
-rw-r--r--client/src/app/shared/menu/top-menu-dropdown.component.html37
-rw-r--r--client/src/app/shared/menu/top-menu-dropdown.component.scss29
-rw-r--r--client/src/app/shared/menu/top-menu-dropdown.component.ts51
-rw-r--r--client/src/app/shared/misc/help.component.scss3
-rw-r--r--client/src/app/shared/misc/list-overflow.component.html35
-rw-r--r--client/src/app/shared/misc/list-overflow.component.scss61
-rw-r--r--client/src/app/shared/misc/list-overflow.component.ts120
-rw-r--r--client/src/app/shared/misc/screen.service.ts4
-rw-r--r--client/src/app/shared/misc/storage.service.ts40
-rw-r--r--client/src/app/shared/moderation/user-ban-modal.component.html11
-rw-r--r--client/src/app/shared/moderation/user-ban-modal.component.ts2
-rw-r--r--client/src/app/shared/moderation/user-moderation-dropdown.component.ts4
-rw-r--r--client/src/app/shared/overview/overview.service.ts9
-rw-r--r--client/src/app/shared/renderer/html-renderer.service.ts7
-rw-r--r--client/src/app/shared/renderer/markdown.service.ts4
-rw-r--r--client/src/app/shared/rest/rest-table.ts51
-rw-r--r--client/src/app/shared/rest/rest.service.ts63
-rw-r--r--client/src/app/shared/shared.module.ts42
-rw-r--r--client/src/app/shared/user-subscription/subscribe-button.component.html2
-rw-r--r--client/src/app/shared/user-subscription/subscribe-button.component.scss12
-rw-r--r--client/src/app/shared/users/user.model.ts46
-rw-r--r--client/src/app/shared/users/user.service.ts82
-rw-r--r--client/src/app/shared/video-abuse/video-abuse.service.ts41
-rw-r--r--client/src/app/shared/video-blacklist/video-blacklist.service.ts16
-rw-r--r--client/src/app/shared/video-channel/video-channel.model.ts9
-rw-r--r--client/src/app/shared/video-channel/video-channel.service.ts7
-rw-r--r--client/src/app/shared/video-import/video-import.service.ts2
-rw-r--r--client/src/app/shared/video-ownership/video-ownership.service.ts2
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.html2
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.scss5
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts2
-rw-r--r--client/src/app/shared/video/abstract-video-list.scss17
-rw-r--r--client/src/app/shared/video/abstract-video-list.ts26
-rw-r--r--client/src/app/shared/video/infinite-scroller.directive.ts4
-rw-r--r--client/src/app/shared/video/modals/video-blacklist.component.html21
-rw-r--r--client/src/app/shared/video/modals/video-blacklist.component.ts6
-rw-r--r--client/src/app/shared/video/modals/video-download.component.html60
-rw-r--r--client/src/app/shared/video/modals/video-download.component.scss35
-rw-r--r--client/src/app/shared/video/modals/video-download.component.ts111
-rw-r--r--client/src/app/shared/video/modals/video-report.component.html16
-rw-r--r--client/src/app/shared/video/modals/video-report.component.ts6
-rw-r--r--client/src/app/shared/video/redundancy.service.ts73
-rw-r--r--client/src/app/shared/video/video-actions-dropdown.component.ts45
-rw-r--r--client/src/app/shared/video/video-miniature.component.html14
-rw-r--r--client/src/app/shared/video/video-miniature.component.scss8
-rw-r--r--client/src/app/shared/video/video-miniature.component.ts5
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.html5
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.scss13
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.ts2
-rw-r--r--client/src/app/shared/video/video.model.ts19
-rw-r--r--client/src/app/shared/video/video.service.ts32
-rw-r--r--client/src/app/shared/video/videos-selection.component.ts4
80 files changed, 1748 insertions, 330 deletions
diff --git a/client/src/app/shared/angular/from-now.pipe.ts b/client/src/app/shared/angular/from-now.pipe.ts
index 3a9a76411..9851468ee 100644
--- a/client/src/app/shared/angular/from-now.pipe.ts
+++ b/client/src/app/shared/angular/from-now.pipe.ts
@@ -12,9 +12,8 @@ export class FromNowPipe implements PipeTransform {
12 const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000) 12 const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000)
13 13
14 let interval = Math.floor(seconds / 31536000) 14 let interval = Math.floor(seconds / 31536000)
15 if (interval > 1) { 15 if (interval > 1) return this.i18n('{{interval}} years ago', { interval })
16 return this.i18n('{{interval}} years ago', { interval }) 16 if (interval === 1) return this.i18n('{{interval}} year ago', { interval })
17 }
18 17
19 interval = Math.floor(seconds / 2592000) 18 interval = Math.floor(seconds / 2592000)
20 if (interval > 1) return this.i18n('{{interval}} months ago', { interval }) 19 if (interval > 1) return this.i18n('{{interval}} months ago', { interval })
@@ -35,6 +34,6 @@ export class FromNowPipe implements PipeTransform {
35 interval = Math.floor(seconds / 60) 34 interval = Math.floor(seconds / 60)
36 if (interval >= 1) return this.i18n('{{interval}} min ago', { interval }) 35 if (interval >= 1) return this.i18n('{{interval}} min ago', { interval })
37 36
38 return this.i18n('{{interval}} sec ago', { interval: Math.max(0, seconds) }) 37 return this.i18n('just now')
39 } 38 }
40} 39}
diff --git a/client/src/app/shared/angular/highlight.pipe.ts b/client/src/app/shared/angular/highlight.pipe.ts
new file mode 100644
index 000000000..fb6042280
--- /dev/null
+++ b/client/src/app/shared/angular/highlight.pipe.ts
@@ -0,0 +1,54 @@
1import { PipeTransform, Pipe } from '@angular/core'
2import { SafeHtml } from '@angular/platform-browser'
3
4// Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369
5@Pipe({ name: 'highlight' })
6export class HighlightPipe implements PipeTransform {
7 /* use this for single match search */
8 static SINGLE_MATCH = 'Single-Match'
9 /* use this for single match search with a restriction that target should start with search string */
10 static SINGLE_AND_STARTS_WITH_MATCH = 'Single-And-StartsWith-Match'
11 /* use this for global search */
12 static MULTI_MATCH = 'Multi-Match'
13
14 // tslint:disable-next-line:no-empty
15 constructor () {}
16
17 transform (
18 contentString: string = null,
19 stringToHighlight: string = null,
20 option = 'Single-And-StartsWith-Match',
21 caseSensitive = false,
22 highlightStyleName = 'search-highlight'
23 ): SafeHtml {
24 if (stringToHighlight && contentString && option) {
25 let regex: any = ''
26 const caseFlag: string = !caseSensitive ? 'i' : ''
27 switch (option) {
28 case 'Single-Match': {
29 regex = new RegExp(stringToHighlight, caseFlag)
30 break
31 }
32 case 'Single-And-StartsWith-Match': {
33 regex = new RegExp('^' + stringToHighlight, caseFlag)
34 break
35 }
36 case 'Multi-Match': {
37 regex = new RegExp(stringToHighlight, 'g' + caseFlag)
38 break
39 }
40 default: {
41 // default will be a global case-insensitive match
42 regex = new RegExp(stringToHighlight, 'gi')
43 }
44 }
45 const replaced = contentString.replace(
46 regex,
47 (match) => `<span class="${highlightStyleName}">${match}</span>`
48 )
49 return replaced
50 } else {
51 return contentString
52 }
53 }
54}
diff --git a/client/src/app/shared/angular/timestamp-route-transformer.directive.ts b/client/src/app/shared/angular/timestamp-route-transformer.directive.ts
index f4d9aeb1f..45e023695 100644
--- a/client/src/app/shared/angular/timestamp-route-transformer.directive.ts
+++ b/client/src/app/shared/angular/timestamp-route-transformer.directive.ts
@@ -10,31 +10,30 @@ export class TimestampRouteTransformerDirective {
10 public onClick ($event: Event) { 10 public onClick ($event: Event) {
11 const target = $event.target as HTMLLinkElement 11 const target = $event.target as HTMLLinkElement
12 12
13 if (target.hasAttribute('href')) { 13 if (target.hasAttribute('href') !== true) return
14 const ngxLink = document.createElement('a') 14
15 ngxLink.href = target.getAttribute('href') 15 const ngxLink = document.createElement('a')
16 16 ngxLink.href = target.getAttribute('href')
17 // we only care about reflective links 17
18 if (ngxLink.host !== window.location.host) return 18 // we only care about reflective links
19 19 if (ngxLink.host !== window.location.host) return
20 const ngxLinkParams = new URLSearchParams(ngxLink.search) 20
21 if (ngxLinkParams.has('start')) { 21 const ngxLinkParams = new URLSearchParams(ngxLink.search)
22 const separators = ['h', 'm', 's'] 22 if (ngxLinkParams.has('start') !== true) return
23 const start = ngxLinkParams 23
24 .get('start') 24 const separators = ['h', 'm', 's']
25 .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator 25 const start = ngxLinkParams
26 .map(t => { 26 .get('start')
27 if (t.includes('h')) return parseInt(t, 10) * 3600 27 .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator
28 if (t.includes('m')) return parseInt(t, 10) * 60 28 .map(t => {
29 return parseInt(t, 10) 29 if (t.includes('h')) return parseInt(t, 10) * 3600
30 }) 30 if (t.includes('m')) return parseInt(t, 10) * 60
31 .reduce((acc, t) => acc + t) 31 return parseInt(t, 10)
32 this.timestampClicked.emit(start) 32 })
33 } 33 .reduce((acc, t) => acc + t)
34 34
35 $event.preventDefault() 35 this.timestampClicked.emit(start)
36 } 36
37 37 $event.preventDefault()
38 return
39 } 38 }
40} 39}
diff --git a/client/src/app/shared/angular/video-duration-formatter.pipe.ts b/client/src/app/shared/angular/video-duration-formatter.pipe.ts
index c92631a75..4b6767415 100644
--- a/client/src/app/shared/angular/video-duration-formatter.pipe.ts
+++ b/client/src/app/shared/angular/video-duration-formatter.pipe.ts
@@ -1,19 +1,28 @@
1import { Pipe, PipeTransform } from '@angular/core' 1import { Pipe, PipeTransform } from '@angular/core'
2 2import { I18n } from '@ngx-translate/i18n-polyfill'
3// Thanks: https://stackoverflow.com/a/46055604
4 3
5@Pipe({ 4@Pipe({
6 name: 'myVideoDurationFormatter' 5 name: 'myVideoDurationFormatter'
7}) 6})
8export class VideoDurationPipe implements PipeTransform { 7export class VideoDurationPipe implements PipeTransform {
8
9 constructor (private i18n: I18n) {
10
11 }
12
9 transform (value: number): string { 13 transform (value: number): string {
10 const minutes = Math.floor(value / 60) 14 const hours = Math.floor(value / 3600)
11 const hours = Math.floor(minutes / 60) 15 const minutes = Math.floor((value % 3600) / 60)
16 const seconds = value % 60
12 17
13 if (hours > 0) { 18 if (hours > 0) {
14 return hours + ' h ' + (minutes - hours * 60) + ' min ' + (value - (minutes - hours * 60) * 60) + ' sec' 19 return this.i18n('{{hours}} h {{minutes}} min {{seconds}} sec', { hours, minutes, seconds })
20 }
21
22 if (minutes > 0) {
23 return this.i18n('{{minutes}} min {{seconds}} sec', { minutes, seconds })
15 } 24 }
16 25
17 return minutes + ' min ' + (value - minutes * 60) + ' sec' 26 return this.i18n('{{seconds}} sec', { seconds })
18 } 27 }
19} 28}
diff --git a/client/src/app/shared/blocklist/blocklist.service.ts b/client/src/app/shared/blocklist/blocklist.service.ts
index c1f7312f0..5cf265bc1 100644
--- a/client/src/app/shared/blocklist/blocklist.service.ts
+++ b/client/src/app/shared/blocklist/blocklist.service.ts
@@ -76,10 +76,14 @@ export class BlocklistService {
76 76
77 /*********************** Instance -> Account blocklist ***********************/ 77 /*********************** Instance -> Account blocklist ***********************/
78 78
79 getInstanceAccountBlocklist (pagination: RestPagination, sort: SortMeta) { 79 getInstanceAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search: string }) {
80 const { pagination, sort, search } = options
81
80 let params = new HttpParams() 82 let params = new HttpParams()
81 params = this.restService.addRestGetParams(params, pagination, sort) 83 params = this.restService.addRestGetParams(params, pagination, sort)
82 84
85 if (search) params = params.append('search', search)
86
83 return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { params }) 87 return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { params })
84 .pipe( 88 .pipe(
85 map(res => this.restExtractor.convertResultListDateToHuman(res)), 89 map(res => this.restExtractor.convertResultListDateToHuman(res)),
@@ -104,10 +108,14 @@ export class BlocklistService {
104 108
105 /*********************** Instance -> Server blocklist ***********************/ 109 /*********************** Instance -> Server blocklist ***********************/
106 110
107 getInstanceServerBlocklist (pagination: RestPagination, sort: SortMeta) { 111 getInstanceServerBlocklist (options: { pagination: RestPagination, sort: SortMeta, search: string }) {
112 const { pagination, sort, search } = options
113
108 let params = new HttpParams() 114 let params = new HttpParams()
109 params = this.restService.addRestGetParams(params, pagination, sort) 115 params = this.restService.addRestGetParams(params, pagination, sort)
110 116
117 if (search) params = params.append('search', search)
118
111 return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', { params }) 119 return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', { params })
112 .pipe( 120 .pipe(
113 map(res => this.restExtractor.convertResultListDateToHuman(res)), 121 map(res => this.restExtractor.convertResultListDateToHuman(res)),
diff --git a/client/src/app/shared/buttons/action-dropdown.component.html b/client/src/app/shared/buttons/action-dropdown.component.html
index cd993db9f..952b3b6f8 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.html
+++ b/client/src/app/shared/buttons/action-dropdown.component.html
@@ -1,4 +1,4 @@
1<div class="dropdown-root" ngbDropdown [placement]="placement" *ngIf="areActionsDisplayed(actions, entry)"> 1<div class="dropdown-root" ngbDropdown [placement]="placement" [container]="container" *ngIf="areActionsDisplayed(actions, entry)">
2 <div 2 <div
3 class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange', 'button-styled': buttonStyled }" 3 class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange', 'button-styled': buttonStyled }"
4 ngbDropdownToggle role="button" 4 ngbDropdownToggle role="button"
@@ -24,17 +24,27 @@
24 </div> 24 </div>
25 </ng-template> 25 </ng-template>
26 26
27 <a *ngIf="action.linkBuilder" [ngClass]="{ 'with-icon': !!action.iconName }" class="dropdown-item" [routerLink]="action.linkBuilder(entry)" [title]="action.title || ''"> 27 <a
28 *ngIf="action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
29 class="dropdown-item" [routerLink]="action.linkBuilder(entry)" [title]="action.title || ''"
30 >
28 <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container> 31 <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
29 </a> 32 </a>
30 33
31 <span 34 <span
32 *ngIf="!action.linkBuilder" [ngClass]="{ 'with-icon': !!action.iconName }" (click)="action.handler(entry)" 35 *ngIf="!action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
33 class="custom-action dropdown-item" role="button" [title]="action.title || ''" 36 class="custom-action dropdown-item" role="button" [title]="action.title || ''" (click)="action.handler(entry)"
34 > 37 >
35 <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container> 38 <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
36 </span> 39 </span>
37 40
41 <h6
42 *ngIf="!action.linkBuilder && action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
43 class="dropdown-header" role="button" [title]="action.title || ''" (click)="action.handler(entry)"
44 >
45 <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
46 </h6>
47
38 </ng-container> 48 </ng-container>
39 </ng-container> 49 </ng-container>
40 50
diff --git a/client/src/app/shared/buttons/action-dropdown.component.scss b/client/src/app/shared/buttons/action-dropdown.component.scss
index 442c90984..7a030f32c 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.scss
+++ b/client/src/app/shared/buttons/action-dropdown.component.scss
@@ -51,6 +51,10 @@
51} 51}
52 52
53.dropdown-menu { 53.dropdown-menu {
54 .dropdown-header {
55 padding: 0.2rem 1rem;
56 }
57
54 .dropdown-item { 58 .dropdown-item {
55 display: flex; 59 display: flex;
56 cursor: pointer; 60 cursor: pointer;
diff --git a/client/src/app/shared/buttons/action-dropdown.component.ts b/client/src/app/shared/buttons/action-dropdown.component.ts
index a8b3ab16c..15f9556dc 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.ts
+++ b/client/src/app/shared/buttons/action-dropdown.component.ts
@@ -9,6 +9,7 @@ export type DropdownAction<T> = {
9 handler?: (a: T) => any 9 handler?: (a: T) => any
10 linkBuilder?: (a: T) => (string | number)[] 10 linkBuilder?: (a: T) => (string | number)[]
11 isDisplayed?: (a: T) => boolean 11 isDisplayed?: (a: T) => boolean
12 isHeader?: boolean
12} 13}
13 14
14export type DropdownButtonSize = 'normal' | 'small' 15export type DropdownButtonSize = 'normal' | 'small'
@@ -26,6 +27,7 @@ export class ActionDropdownComponent<T> {
26 @Input() entry: T 27 @Input() entry: T
27 28
28 @Input() placement = 'bottom-left auto' 29 @Input() placement = 'bottom-left auto'
30 @Input() container: null | 'body'
29 31
30 @Input() buttonSize: DropdownButtonSize = 'normal' 32 @Input() buttonSize: DropdownButtonSize = 'normal'
31 @Input() buttonDirection: DropdownDirection = 'horizontal' 33 @Input() buttonDirection: DropdownDirection = 'horizontal'
@@ -34,10 +36,10 @@ export class ActionDropdownComponent<T> {
34 @Input() label: string 36 @Input() label: string
35 @Input() theme: DropdownTheme = 'grey' 37 @Input() theme: DropdownTheme = 'grey'
36 38
37 getActions () { 39 getActions (): DropdownAction<T>[][] {
38 if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions 40 if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions as DropdownAction<T>[][]
39 41
40 return [ this.actions ] 42 return [ this.actions as DropdownAction<T>[] ]
41 } 43 }
42 44
43 areActionsDisplayed (actions: Array<DropdownAction<T> | DropdownAction<T>[]>, entry: T): boolean { 45 areActionsDisplayed (actions: Array<DropdownAction<T> | DropdownAction<T>[]>, entry: T): boolean {
diff --git a/client/src/app/shared/buttons/button.component.scss b/client/src/app/shared/buttons/button.component.scss
index 2a8cfc748..3ccfefd7e 100644
--- a/client/src/app/shared/buttons/button.component.scss
+++ b/client/src/app/shared/buttons/button.component.scss
@@ -10,11 +10,26 @@ my-small-loader ::ng-deep .root {
10.action-button { 10.action-button {
11 @include peertube-button-link; 11 @include peertube-button-link;
12 @include button-with-icon(21px, 0, -2px); 12 @include button-with-icon(21px, 0, -2px);
13}
13 14
14 // FIXME: Firefox does not apply global .orange-button icon color 15.orange-button {
15 &.orange-button { 16 @include peertube-button;
16 @include apply-svg-color(#fff) 17 @include orange-button;
17 } 18}
19
20.orange-button-link {
21 @include peertube-button-link;
22 @include orange-button;
23}
24
25.grey-button {
26 @include peertube-button;
27 @include grey-button;
28}
29
30.grey-button-link {
31 @include peertube-button-link;
32 @include grey-button;
18} 33}
19 34
20// In a table, try to minimize the space taken by this button 35// In a table, try to minimize the space taken by this button
diff --git a/client/src/app/shared/buttons/edit-button.component.ts b/client/src/app/shared/buttons/edit-button.component.ts
index 1fe4f7b30..9cfe1a3bb 100644
--- a/client/src/app/shared/buttons/edit-button.component.ts
+++ b/client/src/app/shared/buttons/edit-button.component.ts
@@ -8,5 +8,5 @@ import { Component, Input } from '@angular/core'
8 8
9export class EditButtonComponent { 9export class EditButtonComponent {
10 @Input() label: string 10 @Input() label: string
11 @Input() routerLink: string[] = [] 11 @Input() routerLink: string[] | string = []
12} 12}
diff --git a/client/src/app/shared/confirm/confirm.component.html b/client/src/app/shared/confirm/confirm.component.html
index 65df1cd4d..dbc8c23e3 100644
--- a/client/src/app/shared/confirm/confirm.component.html
+++ b/client/src/app/shared/confirm/confirm.component.html
@@ -16,11 +16,15 @@
16 </div> 16 </div>
17 17
18 <div class="modal-footer inputs"> 18 <div class="modal-footer inputs">
19 <span i18n class="action-button action-button-cancel" (click)="dismiss()" role="button">Cancel</span> 19 <input
20 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
21 (click)="dismiss()" (key.enter)="dismiss()"
22 >
20 23
21 <input 24 <input
25 ngbAutofocus
22 type="submit" [value]="confirmButtonText" class="action-button-submit" [disabled]="isConfirmationDisabled()" 26 type="submit" [value]="confirmButtonText" class="action-button-submit" [disabled]="isConfirmationDisabled()"
23 (click)="close()" 27 (click)="close()" (key.enter)="confirm()"
24 > 28 >
25 </div> 29 </div>
26</ng-template> 30</ng-template>
diff --git a/client/src/app/shared/confirm/confirm.component.ts b/client/src/app/shared/confirm/confirm.component.ts
index 763454c4f..c6e40fe72 100644
--- a/client/src/app/shared/confirm/confirm.component.ts
+++ b/client/src/app/shared/confirm/confirm.component.ts
@@ -45,7 +45,6 @@ export class ConfirmComponent implements OnInit {
45 ) 45 )
46 } 46 }
47 47
48 @HostListener('document:keydown.enter')
49 confirm () { 48 confirm () {
50 if (this.openedModal) this.openedModal.close() 49 if (this.openedModal) this.openedModal.close()
51 } 50 }
@@ -60,7 +59,7 @@ export class ConfirmComponent implements OnInit {
60 showModal () { 59 showModal () {
61 this.inputValue = '' 60 this.inputValue = ''
62 61
63 this.openedModal = this.modalService.open(this.confirmModal) 62 this.openedModal = this.modalService.open(this.confirmModal, { centered: true })
64 63
65 this.openedModal.result 64 this.openedModal.result
66 .then(() => this.confirmService.confirmResponse.next(true)) 65 .then(() => this.confirmService.confirmResponse.next(true))
diff --git a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
index 767e3f026..d20754d11 100644
--- a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
+++ b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
@@ -56,7 +56,7 @@ export class CustomConfigValidatorsService {
56 } 56 }
57 57
58 this.SIGNUP_LIMIT = { 58 this.SIGNUP_LIMIT = {
59 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], 59 VALIDATORS: [ Validators.required, Validators.min(-1), Validators.pattern('-?[0-9]+') ],
60 MESSAGES: { 60 MESSAGES: {
61 'required': this.i18n('Signup limit is required.'), 61 'required': this.i18n('Signup limit is required.'),
62 'min': this.i18n('Signup limit must be greater than 1.'), 62 'min': this.i18n('Signup limit must be greater than 1.'),
diff --git a/client/src/app/shared/forms/form-validators/user-validators.service.ts b/client/src/app/shared/forms/form-validators/user-validators.service.ts
index 4dff3e422..13b9228d4 100644
--- a/client/src/app/shared/forms/form-validators/user-validators.service.ts
+++ b/client/src/app/shared/forms/form-validators/user-validators.service.ts
@@ -8,6 +8,7 @@ export class UserValidatorsService {
8 readonly USER_USERNAME: BuildFormValidator 8 readonly USER_USERNAME: BuildFormValidator
9 readonly USER_EMAIL: BuildFormValidator 9 readonly USER_EMAIL: BuildFormValidator
10 readonly USER_PASSWORD: BuildFormValidator 10 readonly USER_PASSWORD: BuildFormValidator
11 readonly USER_PASSWORD_OPTIONAL: BuildFormValidator
11 readonly USER_CONFIRM_PASSWORD: BuildFormValidator 12 readonly USER_CONFIRM_PASSWORD: BuildFormValidator
12 readonly USER_VIDEO_QUOTA: BuildFormValidator 13 readonly USER_VIDEO_QUOTA: BuildFormValidator
13 readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator 14 readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator
@@ -56,6 +57,17 @@ export class UserValidatorsService {
56 } 57 }
57 } 58 }
58 59
60 this.USER_PASSWORD_OPTIONAL = {
61 VALIDATORS: [
62 Validators.minLength(6),
63 Validators.maxLength(255)
64 ],
65 MESSAGES: {
66 'minlength': this.i18n('Password must be at least 6 characters long.'),
67 'maxlength': this.i18n('Password cannot be more than 255 characters long.')
68 }
69 }
70
59 this.USER_CONFIRM_PASSWORD = { 71 this.USER_CONFIRM_PASSWORD = {
60 VALIDATORS: [], 72 VALIDATORS: [],
61 MESSAGES: { 73 MESSAGES: {
diff --git a/client/src/app/shared/forms/input-readonly-copy.component.html b/client/src/app/shared/forms/input-readonly-copy.component.html
index 27571b63f..9566e9741 100644
--- a/client/src/app/shared/forms/input-readonly-copy.component.html
+++ b/client/src/app/shared/forms/input-readonly-copy.component.html
@@ -1,8 +1,8 @@
1<div class="input-group"> 1<div class="input-group input-group-sm">
2 <input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" /> 2 <input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" />
3 3
4 <div class="input-group-append"> 4 <div class="input-group-append">
5 <button [ngxClipboard]="urlInput" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary"> 5 <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
6 <span class="glyphicon glyphicon-copy"></span> 6 <span class="glyphicon glyphicon-copy"></span>
7 </button> 7 </button>
8 </div> 8 </div>
diff --git a/client/src/app/shared/forms/markdown-textarea.component.html b/client/src/app/shared/forms/markdown-textarea.component.html
index 0925b9ad5..a519f3e0a 100644
--- a/client/src/app/shared/forms/markdown-textarea.component.html
+++ b/client/src/app/shared/forms/markdown-textarea.component.html
@@ -1,17 +1,36 @@
1<div class="root" [ngStyle]="{ 'flex-direction': flexDirection }"> 1<div class="root" [ngClass]="{ 'maximized': isMaximized }" [ngStyle]="{ 'max-width': textareaMaxWidth }">
2 <textarea 2 <textarea #textarea
3 [(ngModel)]="content" (ngModelChange)="onModelChange()" 3 [(ngModel)]="content" (ngModelChange)="onModelChange()"
4 [ngClass]="classes" [ngStyle]="{ width: textareaWidth, height: textareaHeight, 'margin-right': textareaMarginRight }" 4 class="form-control" [ngClass]="classes"
5 [ngStyle]="{ height: textareaHeight }"
5 [id]="name" [name]="name"> 6 [id]="name" [name]="name">
6 </textarea> 7 </textarea>
7 8
8 <ngb-tabset *ngIf="arePreviewsDisplayed()" class="previews" type="pills"> 9 <div ngbNav #nav="ngbNav" class="nav-pills nav-preview">
9 <ngb-tab *ngIf="truncate !== undefined" i18n-title title="Truncated preview"> 10 <ng-container ngbNavItem *ngIf="truncate !== undefined">
10 <ng-template ngbTabContent><div [innerHTML]="truncatedPreviewHTML"></div></ng-template> 11 <a ngbNavLink i18n>Truncated preview</a>
11 </ngb-tab>
12 12
13 <ngb-tab i18n-title title="Complete preview"> 13 <ng-template ngbNavContent>
14 <ng-template ngbTabContent><div [innerHTML]="previewHTML"></div></ng-template> 14 <div [innerHTML]="truncatedPreviewHTML"></div>
15 </ngb-tab> 15 </ng-template>
16 </ngb-tabset> 16 </ng-container>
17
18 <ng-container ngbNavItem>
19 <a ngbNavLink i18n>Complete preview</a>
20
21 <ng-template ngbNavContent>
22 <div [innerHTML]="previewHTML"></div>
23 </ng-template>
24 </ng-container>
25
26 <my-button
27 *ngIf="!isMaximized" icon="fullscreen" (click)="onMaximizeClick()"
28 ></my-button>
29
30 <my-button
31 *ngIf="isMaximized" icon="exit-fullscreen" (click)="onMaximizeClick()"
32 ></my-button>
33 </div>
34
35 <div [ngbNavOutlet]="nav"></div>
17</div> 36</div>
diff --git a/client/src/app/shared/forms/markdown-textarea.component.scss b/client/src/app/shared/forms/markdown-textarea.component.scss
index eacaf36a2..8e5739e45 100644
--- a/client/src/app/shared/forms/markdown-textarea.component.scss
+++ b/client/src/app/shared/forms/markdown-textarea.component.scss
@@ -1,34 +1,250 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4.root { 4$nav-preview-tab-height: 30px;
5 display: flex; 5$base-padding: 15px;
6$input-border-color: #C6C6C6;
7$input-border-radius: 3px;
8
9@mixin in-small-view {
10 .root {
11 display: flex;
12 flex-direction: column;
13
14 textarea {
15 @include peertube-textarea(100%, 150px);
16
17 background-color: var(--textareaBackgroundColor);
18 font-family: monospace;
19 font-size: 13px;
20 border-bottom: none;
21 border-bottom-left-radius: unset;
22 border-bottom-right-radius: unset;
23 }
6 24
7 textarea { 25 .nav-preview {
8 @include peertube-textarea(100%, 150px); 26 display: block;
27 text-align: right;
28 padding-top: 10px;
29 padding-bottom: 10px;
30 padding-left: 10px;
31 padding-right: 10px;
32 border-top: 1px dashed $input-border-color;
33 border-left: 1px solid $input-border-color;
34 border-right: 1px solid $input-border-color;
35 border-bottom: 1px solid $input-border-color;
36 border-bottom-right-radius: $input-border-radius;
9 37
10 margin-bottom: 15px; 38 border-bottom-left-radius: $input-border-radius;
39 ::ng-deep {
40 .nav-link {
41 display: none !important;
42 }
43
44 .grey-button {
45 padding: 0 12px 0 12px;
46 }
47 }
48 }
49
50 ::ng-deep {
51 .tab-content {
52 display: none;
53 }
54 }
11 } 55 }
56}
57
58@mixin nav-preview-medium {
59 display: flex;
60 flex-grow: 1;
61 border-bottom-left-radius: unset;
62 border-bottom-right-radius: unset;
63 border-bottom: 2px solid var(--mainColor);
12 64
13 .previews { 65 :first-child {
14 max-height: 150px; 66 margin-left: auto;
15 overflow-y: auto;
16 flex-grow: 1;
17 } 67 }
18 68
19 ::ng-deep { 69 ::ng-deep {
20 .nav-link { 70 .nav-link {
21 display: flex !important; 71 display: flex !important;
22 align-items: center; 72 align-items: center;
23 height: 30px !important; 73 height: $nav-preview-tab-height !important;
24 padding: 0 15px !important; 74 padding: 0 15px !important;
75 font-size: 85% !important;
76 opacity: .7;
77 }
78
79 .grey-button {
80 margin-left: 5px;
81 }
82 }
83}
84
85@mixin content-preview-base {
86 display: block;
87 min-height: 75px;
88 padding: $base-padding;
89 overflow-y: auto;
90 font-size: 15px;
91 word-wrap: break-word;
92}
93
94@mixin maximized-base {
95 flex-direction: row;
96 z-index: #{z(header) - 1};
97 position: fixed;
98 top: $header-height;
99 left: $menu-width;
100 max-height: none !important;
101 max-width: none !important;
102 width: calc(100% - #{$menu-width});
103 height: calc(100vh - #{$header-height}) !important;
104
105 $nav-preview-vertical-padding: 40px;
106
107 .nav-preview {
108 @include nav-preview-medium();
109 padding-top: #{$nav-preview-vertical-padding / 2};
110 padding-bottom: #{$nav-preview-vertical-padding / 2};
111 padding-left: 0px;
112 padding-right: 0px;
113 position: absolute;
114 background-color: var(--mainBackgroundColor);
115 width: 100% !important;
116 border-top: none;
117 border-left: none;
118 border-right: none;
119
120 :last-child {
121 margin-right: $not-expanded-horizontal-margins;
25 } 122 }
123 }
124
125 ::ng-deep .tab-content {
126 @include content-preview-base();
127 background-color: var(--mainBackgroundColor);
128 scrollbar-color: var(--actionButtonColor) var(--mainBackgroundColor);
129 }
26 130
27 .tab-content { 131 textarea,
28 min-height: 75px; 132 ::ng-deep .tab-content {
29 padding: 15px; 133 max-height: none !important;
30 font-size: 15px; 134 max-width: none !important;
31 word-wrap: break-word; 135 margin-top: #{$nav-preview-tab-height + $nav-preview-vertical-padding} !important;
136 height: calc(100vh - #{$header-height + $nav-preview-tab-height + $nav-preview-vertical-padding}) !important;
137 width: 50% !important;
138 border: none !important;
139 border-radius: unset !important;
140 }
141
142 :host-context(.expanded) {
143 .root.maximized {
144 left: 0;
145 width: 100%;
146 }
147 }
148}
149
150@mixin maximized-in-small-view {
151 .root.maximized {
152 @include maximized-base();
153
154 textarea {
155 display: none;
32 } 156 }
157
158 ::ng-deep .tab-content {
159 width: 100% !important;
160 }
161 }
162}
163
164@mixin maximized-tabs-in-mobile-view {
165 // Ellipsis on tabs for mobile view
166 .root.maximized {
167 .nav-preview {
168 ::ng-deep .nav-link {
169 @include ellipsis();
170
171 display: block !important;
172 max-width: 45% !important;
173 padding: 5px 0 !important;
174 margin-right: 10px !important;
175 text-align: center;
176
177 &:not(.active) {
178 max-width: 15% !important;
179 }
180
181 &.active {
182 padding: 5px 15px !important;
183 }
184 }
185 }
186 }
187}
188
189@mixin in-medium-view {
190 .root {
191 .nav-preview {
192 @include nav-preview-medium();
193 }
194
195 ::ng-deep .tab-content {
196 @include content-preview-base();
197 max-height: 210px;
198 border-bottom: 1px solid $input-border-color;
199 border-left: 1px solid $input-border-color;
200 border-right: 1px solid $input-border-color;
201 border-bottom-left-radius: $input-border-radius;
202 border-bottom-right-radius: $input-border-radius;
203 }
204 }
205}
206
207@mixin maximized-in-medium-view {
208 .root.maximized {
209 @include maximized-base();
210
211 textarea {
212 display: block;
213 padding: $base-padding;
214 border-right: 1px dashed $input-border-color !important;
215 resize: none;
216 scrollbar-color: var(--actionButtonColor) var(--textareaBackgroundColor);
217
218 &:focus {
219 box-shadow: none;
220 }
221 }
222 }
223}
224
225@include in-small-view();
226@include maximized-in-small-view();
227
228@media only screen and (max-width: $mobile-view) {
229 @include maximized-tabs-in-mobile-view();
230}
231
232@media only screen and (max-width: #{$mobile-view + $menu-width}) {
233 :host-context(.main-col:not(.expanded)) {
234 @include maximized-tabs-in-mobile-view();
235 }
236}
237
238@media only screen and (min-width: $small-view) {
239 :host-context(.expanded) {
240 @include in-medium-view();
241 }
242
243 @include maximized-in-medium-view();
244}
245
246@media only screen and (min-width: #{$small-view + $menu-width}) {
247 :host-context(.main-col:not(.expanded)) {
248 @include in-medium-view();
33 } 249 }
34} 250}
diff --git a/client/src/app/shared/forms/markdown-textarea.component.ts b/client/src/app/shared/forms/markdown-textarea.component.ts
index 19cd37573..dde7b4d98 100644
--- a/client/src/app/shared/forms/markdown-textarea.component.ts
+++ b/client/src/app/shared/forms/markdown-textarea.component.ts
@@ -1,5 +1,5 @@
1import { debounceTime, distinctUntilChanged } from 'rxjs/operators' 1import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
2import { Component, forwardRef, Input, OnInit } from '@angular/core' 2import { Component, forwardRef, Input, OnInit, ViewChild, ElementRef } from '@angular/core'
3import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 3import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
4import { Subject } from 'rxjs' 4import { Subject } from 'rxjs'
5import truncate from 'lodash-es/truncate' 5import truncate from 'lodash-es/truncate'
@@ -21,19 +21,19 @@ import { MarkdownService } from '@app/shared/renderer'
21 21
22export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { 22export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
23 @Input() content = '' 23 @Input() content = ''
24 @Input() classes: string[] = [] 24 @Input() classes: string[] | { [klass: string]: any[] | any } = []
25 @Input() textareaWidth = '100%' 25 @Input() textareaMaxWidth = '100%'
26 @Input() textareaHeight = '150px' 26 @Input() textareaHeight = '150px'
27 @Input() previewColumn = false
28 @Input() truncate: number 27 @Input() truncate: number
29 @Input() markdownType: 'text' | 'enhanced' = 'text' 28 @Input() markdownType: 'text' | 'enhanced' = 'text'
30 @Input() markdownVideo = false 29 @Input() markdownVideo = false
31 @Input() name = 'description' 30 @Input() name = 'description'
32 31
33 textareaMarginRight = '0' 32 @ViewChild('textarea') textareaElement: ElementRef
34 flexDirection = 'column' 33
35 truncatedPreviewHTML = '' 34 truncatedPreviewHTML = ''
36 previewHTML = '' 35 previewHTML = ''
36 isMaximized = false
37 37
38 private contentChanged = new Subject<string>() 38 private contentChanged = new Subject<string>()
39 39
@@ -51,11 +51,6 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
51 .subscribe(() => this.updatePreviews()) 51 .subscribe(() => this.updatePreviews())
52 52
53 this.contentChanged.next(this.content) 53 this.contentChanged.next(this.content)
54
55 if (this.previewColumn) {
56 this.flexDirection = 'row'
57 this.textareaMarginRight = '15px'
58 }
59 } 54 }
60 55
61 propagateChange = (_: any) => { /* empty */ } 56 propagateChange = (_: any) => { /* empty */ }
@@ -80,8 +75,26 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
80 this.contentChanged.next(this.content) 75 this.contentChanged.next(this.content)
81 } 76 }
82 77
83 arePreviewsDisplayed () { 78 onMaximizeClick () {
84 return this.screenService.isInSmallView() === false 79 this.isMaximized = !this.isMaximized
80
81 // Make sure textarea have the focus
82 this.textareaElement.nativeElement.focus()
83
84 // Make sure the window has no scrollbars
85 if (!this.isMaximized) {
86 this.unlockBodyScroll()
87 } else {
88 this.lockBodyScroll()
89 }
90 }
91
92 private lockBodyScroll () {
93 document.getElementById('content').classList.add('lock-scroll')
94 }
95
96 private unlockBodyScroll () {
97 document.getElementById('content').classList.remove('lock-scroll')
85 } 98 }
86 99
87 private async updatePreviews () { 100 private async updatePreviews () {
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.html b/client/src/app/shared/forms/peertube-checkbox.component.html
index c740d852c..704f3e696 100644
--- a/client/src/app/shared/forms/peertube-checkbox.component.html
+++ b/client/src/app/shared/forms/peertube-checkbox.component.html
@@ -29,6 +29,8 @@
29 <ng-template *ngTemplateOutlet="helpTemplate"></ng-template> 29 <ng-template *ngTemplateOutlet="helpTemplate"></ng-template>
30 </ng-template> 30 </ng-template>
31 </my-help> 31 </my-help>
32
33 <div *ngIf="recommended" class="recommended" i18n>Recommended</div>
32 </div> 34 </div>
33 35
34 <div class="ml-4 d-flex flex-column"> 36 <div class="ml-4 d-flex flex-column">
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.scss b/client/src/app/shared/forms/peertube-checkbox.component.scss
index 3120509b3..c1233e8a5 100644
--- a/client/src/app/shared/forms/peertube-checkbox.component.scss
+++ b/client/src/app/shared/forms/peertube-checkbox.component.scss
@@ -34,4 +34,19 @@
34 .wrapper:empty { 34 .wrapper:empty {
35 display: none; 35 display: none;
36 } 36 }
37
38 .recommended {
39 margin-left: .5rem;
40 align-self: baseline;
41 display: inline-block;
42 padding: 4px 6px;
43 cursor: default;
44 border-radius: 3px;
45 font-size: 12px;
46 line-height: 12px;
47 font-weight: 500;
48 color: var(--inputPlaceholderColor);
49 background-color: rgba(217,225,232,.1);
50 border: 1px solid rgba(217,225,232,.5);
51 }
37} \ No newline at end of file 52} \ No newline at end of file
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.ts b/client/src/app/shared/forms/peertube-checkbox.component.ts
index cb7ec8eda..89e79fecd 100644
--- a/client/src/app/shared/forms/peertube-checkbox.component.ts
+++ b/client/src/app/shared/forms/peertube-checkbox.component.ts
@@ -21,6 +21,7 @@ export class PeertubeCheckboxComponent implements ControlValueAccessor, AfterCon
21 @Input() labelInnerHTML: string 21 @Input() labelInnerHTML: string
22 @Input() helpPlacement = 'top auto' 22 @Input() helpPlacement = 'top auto'
23 @Input() disabled = false 23 @Input() disabled = false
24 @Input() recommended = false
24 25
25 @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'label' | 'help'>> 26 @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'label' | 'help'>>
26 27
diff --git a/client/src/app/shared/forms/timestamp-input.component.scss b/client/src/app/shared/forms/timestamp-input.component.scss
index e7dbcd997..9671cc65f 100644
--- a/client/src/app/shared/forms/timestamp-input.component.scss
+++ b/client/src/app/shared/forms/timestamp-input.component.scss
@@ -1,8 +1,15 @@
1@import 'variables';
2
1p-inputmask { 3p-inputmask {
2 ::ng-deep input { 4 ::ng-deep input {
3 width: 80px; 5 width: 80px;
4 font-size: 15px; 6 font-size: 15px;
5 7
6 border: none; 8 border: none;
9
10 &:focus-within,
11 &:focus {
12 box-shadow: #{$focus-box-shadow-form} var(--mainColorLightest);
13 }
7 } 14 }
8} 15}
diff --git a/client/src/app/shared/images/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts
index 806aca347..d2700f6c3 100644
--- a/client/src/app/shared/images/global-icon.component.ts
+++ b/client/src/app/shared/images/global-icon.component.ts
@@ -1,57 +1,62 @@
1import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core' 1import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core'
2import { HooksService } from '@app/core/plugins/hooks.service' 2import { HooksService } from '@app/core/plugins/hooks.service'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4 3
5const icons = { 4const icons = {
6 'add': require('!!raw-loader?!../../../assets/images/global/add.svg'), 5 'add': require('!!raw-loader?!../../../assets/images/global/add.svg').default,
7 'user': require('!!raw-loader?!../../../assets/images/global/user.svg'), 6 'user': require('!!raw-loader?!../../../assets/images/global/user.svg').default,
8 'sign-out': require('!!raw-loader?!../../../assets/images/global/sign-out.svg'), 7 'sign-out': require('!!raw-loader?!../../../assets/images/global/sign-out.svg').default,
9 'syndication': require('!!raw-loader?!../../../assets/images/global/syndication.svg'), 8 'syndication': require('!!raw-loader?!../../../assets/images/global/syndication.svg').default,
10 'help': require('!!raw-loader?!../../../assets/images/global/help.svg'), 9 'help': require('!!raw-loader?!../../../assets/images/global/help.svg').default,
11 'sparkle': require('!!raw-loader?!../../../assets/images/global/sparkle.svg'), 10 'sparkle': require('!!raw-loader?!../../../assets/images/global/sparkle.svg').default,
12 'alert': require('!!raw-loader?!../../../assets/images/global/alert.svg'), 11 'alert': require('!!raw-loader?!../../../assets/images/global/alert.svg').default,
13 'cloud-error': require('!!raw-loader?!../../../assets/images/global/cloud-error.svg'), 12 'cloud-error': require('!!raw-loader?!../../../assets/images/global/cloud-error.svg').default,
14 'clock': require('!!raw-loader?!../../../assets/images/global/clock.svg'), 13 'clock': require('!!raw-loader?!../../../assets/images/global/clock.svg').default,
15 'user-add': require('!!raw-loader?!../../../assets/images/global/user-add.svg'), 14 'user-add': require('!!raw-loader?!../../../assets/images/global/user-add.svg').default,
16 'no': require('!!raw-loader?!../../../assets/images/global/no.svg'), 15 'no': require('!!raw-loader?!../../../assets/images/global/no.svg').default,
17 'cloud-download': require('!!raw-loader?!../../../assets/images/global/cloud-download.svg'), 16 'cloud-download': require('!!raw-loader?!../../../assets/images/global/cloud-download.svg').default,
18 'undo': require('!!raw-loader?!../../../assets/images/global/undo.svg'), 17 'undo': require('!!raw-loader?!../../../assets/images/global/undo.svg').default,
19 'history': require('!!raw-loader?!../../../assets/images/global/history.svg'), 18 'history': require('!!raw-loader?!../../../assets/images/global/history.svg').default,
20 'circle-tick': require('!!raw-loader?!../../../assets/images/global/circle-tick.svg'), 19 'circle-tick': require('!!raw-loader?!../../../assets/images/global/circle-tick.svg').default,
21 'cog': require('!!raw-loader?!../../../assets/images/global/cog.svg'), 20 'cog': require('!!raw-loader?!../../../assets/images/global/cog.svg').default,
22 'download': require('!!raw-loader?!../../../assets/images/global/download.svg'), 21 'download': require('!!raw-loader?!../../../assets/images/global/download.svg').default,
23 'go': require('!!raw-loader?!../../../assets/images/menu/go.svg'), 22 'go': require('!!raw-loader?!../../../assets/images/menu/go.svg').default,
24 'edit': require('!!raw-loader?!../../../assets/images/global/edit.svg'), 23 'edit': require('!!raw-loader?!../../../assets/images/global/edit.svg').default,
25 'im-with-her': require('!!raw-loader?!../../../assets/images/global/im-with-her.svg'), 24 'im-with-her': require('!!raw-loader?!../../../assets/images/global/im-with-her.svg').default,
26 'delete': require('!!raw-loader?!../../../assets/images/global/delete.svg'), 25 'delete': require('!!raw-loader?!../../../assets/images/global/delete.svg').default,
27 'server': require('!!raw-loader?!../../../assets/images/global/server.svg'), 26 'server': require('!!raw-loader?!../../../assets/images/global/server.svg').default,
28 'cross': require('!!raw-loader?!../../../assets/images/global/cross.svg'), 27 'cross': require('!!raw-loader?!../../../assets/images/global/cross.svg').default,
29 'validate': require('!!raw-loader?!../../../assets/images/global/validate.svg'), 28 'validate': require('!!raw-loader?!../../../assets/images/global/validate.svg').default,
30 'tick': require('!!raw-loader?!../../../assets/images/global/tick.svg'), 29 'tick': require('!!raw-loader?!../../../assets/images/global/tick.svg').default,
31 'repeat': require('!!raw-loader?!../../../assets/images/global/repeat.svg'), 30 'repeat': require('!!raw-loader?!../../../assets/images/global/repeat.svg').default,
32 'inbox-full': require('!!raw-loader?!../../../assets/images/global/inbox-full.svg'), 31 'inbox-full': require('!!raw-loader?!../../../assets/images/global/inbox-full.svg').default,
33 'dislike': require('!!raw-loader?!../../../assets/images/video/dislike.svg'), 32 'dislike': require('!!raw-loader?!../../../assets/images/video/dislike.svg').default,
34 'support': require('!!raw-loader?!../../../assets/images/video/support.svg'), 33 'support': require('!!raw-loader?!../../../assets/images/video/support.svg').default,
35 'like': require('!!raw-loader?!../../../assets/images/video/like.svg'), 34 'like': require('!!raw-loader?!../../../assets/images/video/like.svg').default,
36 'more-horizontal': require('!!raw-loader?!../../../assets/images/global/more-horizontal.svg'), 35 'more-horizontal': require('!!raw-loader?!../../../assets/images/global/more-horizontal.svg').default,
37 'more-vertical': require('!!raw-loader?!../../../assets/images/global/more-vertical.svg'), 36 'more-vertical': require('!!raw-loader?!../../../assets/images/global/more-vertical.svg').default,
38 'share': require('!!raw-loader?!../../../assets/images/video/share.svg'), 37 'share': require('!!raw-loader?!../../../assets/images/video/share.svg').default,
39 'upload': require('!!raw-loader?!../../../assets/images/video/upload.svg'), 38 'upload': require('!!raw-loader?!../../../assets/images/video/upload.svg').default,
40 'playlist-add': require('!!raw-loader?!../../../assets/images/video/playlist-add.svg'), 39 'playlist-add': require('!!raw-loader?!../../../assets/images/video/playlist-add.svg').default,
41 'play': require('!!raw-loader?!../../../assets/images/global/play.svg'), 40 'play': require('!!raw-loader?!../../../assets/images/global/play.svg').default,
42 'playlists': require('!!raw-loader?!../../../assets/images/global/playlists.svg'), 41 'playlists': require('!!raw-loader?!../../../assets/images/global/playlists.svg').default,
43 'about': require('!!raw-loader?!../../../assets/images/menu/about.svg'), 42 'globe': require('!!raw-loader?!../../../assets/images/menu/globe.svg').default,
44 'globe': require('!!raw-loader?!../../../assets/images/menu/globe.svg'), 43 'home': require('!!raw-loader?!../../../assets/images/menu/home.svg').default,
45 'home': require('!!raw-loader?!../../../assets/images/menu/home.svg'), 44 'recently-added': require('!!raw-loader?!../../../assets/images/menu/recently-added.svg').default,
46 'recently-added': require('!!raw-loader?!../../../assets/images/menu/recently-added.svg'), 45 'trending': require('!!raw-loader?!../../../assets/images/menu/trending.svg').default,
47 'trending': require('!!raw-loader?!../../../assets/images/menu/trending.svg'), 46 'video-lang': require('!!raw-loader?!../../../assets/images/global/video-lang.svg').default,
48 'videos': require('!!raw-loader?!../../../assets/images/global/videos.svg'), 47 'videos': require('!!raw-loader?!../../../assets/images/global/videos.svg').default,
49 'folder': require('!!raw-loader?!../../../assets/images/global/folder.svg'), 48 'folder': require('!!raw-loader?!../../../assets/images/global/folder.svg').default,
50 'administration': require('!!raw-loader?!../../../assets/images/menu/administration.svg'), 49 'subscriptions': require('!!raw-loader?!../../../assets/images/menu/subscriptions.svg').default,
51 'subscriptions': require('!!raw-loader?!../../../assets/images/menu/subscriptions.svg'), 50 'language': require('!!raw-loader?!../../../assets/images/menu/language.svg').default,
52 'users': require('!!raw-loader?!../../../assets/images/global/users.svg'), 51 'unsensitive': require('!!raw-loader?!../../../assets/images/menu/eye.svg').default,
53 'search': require('!!raw-loader?!../../../assets/images/global/search.svg'), 52 'sensitive': require('!!raw-loader?!../../../assets/images/menu/eye-closed.svg').default,
54 'refresh': require('!!raw-loader?!../../../assets/images/global/refresh.svg') 53 'p2p': require('!!raw-loader?!../../../assets/images/menu/p2p.svg').default,
54 'users': require('!!raw-loader?!../../../assets/images/global/users.svg').default,
55 'search': require('!!raw-loader?!../../../assets/images/global/search.svg').default,
56 'refresh': require('!!raw-loader?!../../../assets/images/global/refresh.svg').default,
57 'npm': require('!!raw-loader?!../../../assets/images/global/npm.svg').default,
58 'fullscreen': require('!!raw-loader?!../../../assets/images/global/fullscreen.svg').default,
59 'exit-fullscreen': require('!!raw-loader?!../../../assets/images/global/exit-fullscreen.svg').default
55} 60}
56 61
57export type GlobalIconName = keyof typeof icons 62export type GlobalIconName = keyof typeof icons
diff --git a/client/src/app/shared/images/preview-upload.component.html b/client/src/app/shared/images/preview-upload.component.html
index 5e1d5211b..7c3a2b588 100644
--- a/client/src/app/shared/images/preview-upload.component.html
+++ b/client/src/app/shared/images/preview-upload.component.html
@@ -1,13 +1,11 @@
1<div class="root"> 1<div class="root">
2 <div class="preview-container"> 2 <div class="preview-container">
3 <my-reactive-file 3 <my-reactive-file
4 [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize" 4 [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize" placement="right"
5 icon="edit" (fileChanged)="onFileChanged($event)" 5 icon="edit" (fileChanged)="onFileChanged($event)" [ngbTooltip]="'(extensions: '+ videoImageExtensions +', '+ maxSizeText +': '+ maxVideoImageSizeInBytes +')'"
6 ></my-reactive-file> 6 ></my-reactive-file>
7 7
8 <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" /> 8 <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
9 <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div> 9 <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
10 </div> 10 </div>
11
12 <div i18n class="file-constraints">(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxVideoImageSize | bytes }})</div>
13</div> 11</div>
diff --git a/client/src/app/shared/images/preview-upload.component.scss b/client/src/app/shared/images/preview-upload.component.scss
index 257060239..8f3522115 100644
--- a/client/src/app/shared/images/preview-upload.component.scss
+++ b/client/src/app/shared/images/preview-upload.component.scss
@@ -16,11 +16,13 @@
16 } 16 }
17 17
18 .preview { 18 .preview {
19 border: 2px solid grey; 19 object-fit: cover;
20 border-radius: 4px; 20 border-radius: 4px;
21 max-width: 100%;
21 22
22 &.no-image { 23 &.no-image {
23 background-color: #ececec; 24 border: 2px solid grey;
25 background-color: var(--mainBackgroundColor);
24 } 26 }
25 } 27 }
26 } 28 }
diff --git a/client/src/app/shared/images/preview-upload.component.ts b/client/src/app/shared/images/preview-upload.component.ts
index f56f5b1f8..7519734ba 100644
--- a/client/src/app/shared/images/preview-upload.component.ts
+++ b/client/src/app/shared/images/preview-upload.component.ts
@@ -3,6 +3,8 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' 3import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
4import { ServerService } from '@app/core' 4import { ServerService } from '@app/core'
5import { ServerConfig } from '@shared/models' 5import { ServerConfig } from '@shared/models'
6import { BytesPipe } from 'ngx-pipes'
7import { I18n } from '@ngx-translate/i18n-polyfill'
6 8
7@Component({ 9@Component({
8 selector: 'my-preview-upload', 10 selector: 'my-preview-upload',
@@ -24,14 +26,20 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
24 26
25 imageSrc: SafeResourceUrl 27 imageSrc: SafeResourceUrl
26 allowedExtensionsMessage = '' 28 allowedExtensionsMessage = ''
29 maxSizeText: string
27 30
28 private serverConfig: ServerConfig 31 private serverConfig: ServerConfig
29 private file: File 32 private bytesPipe: BytesPipe
33 private file: Blob
30 34
31 constructor ( 35 constructor (
32 private sanitizer: DomSanitizer, 36 private sanitizer: DomSanitizer,
33 private serverService: ServerService 37 private serverService: ServerService,
34 ) {} 38 private i18n: I18n
39 ) {
40 this.bytesPipe = new BytesPipe()
41 this.maxSizeText = this.i18n('max size')
42 }
35 43
36 get videoImageExtensions () { 44 get videoImageExtensions () {
37 return this.serverConfig.video.image.extensions 45 return this.serverConfig.video.image.extensions
@@ -41,6 +49,10 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
41 return this.serverConfig.video.image.size.max 49 return this.serverConfig.video.image.size.max
42 } 50 }
43 51
52 get maxVideoImageSizeInBytes () {
53 return this.bytesPipe.transform(this.maxVideoImageSize)
54 }
55
44 ngOnInit () { 56 ngOnInit () {
45 this.serverConfig = this.serverService.getTmpConfig() 57 this.serverConfig = this.serverService.getTmpConfig()
46 this.serverService.getConfig() 58 this.serverService.getConfig()
@@ -49,7 +61,7 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
49 this.allowedExtensionsMessage = this.videoImageExtensions.join(', ') 61 this.allowedExtensionsMessage = this.videoImageExtensions.join(', ')
50 } 62 }
51 63
52 onFileChanged (file: File) { 64 onFileChanged (file: Blob) {
53 this.file = file 65 this.file = file
54 66
55 this.propagateChange(this.file) 67 this.propagateChange(this.file)
diff --git a/client/src/app/shared/instance/instance-features-table.component.html b/client/src/app/shared/instance/instance-features-table.component.html
index fd8b3354f..99b854d13 100644
--- a/client/src/app/shared/instance/instance-features-table.component.html
+++ b/client/src/app/shared/instance/instance-features-table.component.html
@@ -37,8 +37,8 @@
37 <tr> 37 <tr>
38 <td i18n class="sub-label">Video uploads</td> 38 <td i18n class="sub-label">Video uploads</td>
39 <td> 39 <td>
40 <span *ngIf="serverConfig.autoBlacklist.videos.ofUsers.enabled">Requires manual validation by moderators</span> 40 <span i18n *ngIf="serverConfig.autoBlacklist.videos.ofUsers.enabled">Requires manual validation by moderators</span>
41 <span *ngIf="!serverConfig.autoBlacklist.videos.ofUsers.enabled">Automatically published</span> 41 <span i18n *ngIf="!serverConfig.autoBlacklist.videos.ofUsers.enabled">Automatically published</span>
42 </td> 42 </td>
43 </tr> 43 </tr>
44 44
@@ -91,5 +91,16 @@
91 <my-feature-boolean [value]="serverConfig.tracker.enabled"></my-feature-boolean> 91 <my-feature-boolean [value]="serverConfig.tracker.enabled"></my-feature-boolean>
92 </td> 92 </td>
93 </tr> 93 </tr>
94
95 <tr>
96 <td i18n class="label" colspan="2">Search</td>
97 </tr>
98
99 <tr>
100 <td i18n class="sub-label">Users can resolve distant content</td>
101 <td>
102 <my-feature-boolean [value]="serverConfig.search.remoteUri.users"></my-feature-boolean>
103 </td>
104 </tr>
94 </table> 105 </table>
95</div> 106</div>
diff --git a/client/src/app/shared/instance/instance-statistics.component.ts b/client/src/app/shared/instance/instance-statistics.component.ts
index 8ec728f05..40aa8a4c0 100644
--- a/client/src/app/shared/instance/instance-statistics.component.ts
+++ b/client/src/app/shared/instance/instance-statistics.component.ts
@@ -1,9 +1,6 @@
1import { map } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { ServerStats } from '@shared/models/server' 2import { ServerStats } from '@shared/models/server'
6import { environment } from '../../../environments/environment' 3import { ServerService } from '@app/core'
7 4
8@Component({ 5@Component({
9 selector: 'my-instance-statistics', 6 selector: 'my-instance-statistics',
@@ -11,27 +8,15 @@ import { environment } from '../../../environments/environment'
11 styleUrls: [ './instance-statistics.component.scss' ] 8 styleUrls: [ './instance-statistics.component.scss' ]
12}) 9})
13export class InstanceStatisticsComponent implements OnInit { 10export class InstanceStatisticsComponent implements OnInit {
14 private static BASE_STATS_URL = environment.apiUrl + '/api/v1/server/stats'
15
16 serverStats: ServerStats = null 11 serverStats: ServerStats = null
17 12
18 constructor ( 13 constructor (
19 private http: HttpClient, 14 private serverService: ServerService
20 private i18n: I18n
21 ) { 15 ) {
22 } 16 }
23 17
24 ngOnInit () { 18 ngOnInit () {
25 this.getStats() 19 this.serverService.getServerStats()
26 .subscribe( 20 .subscribe(res => this.serverStats = res)
27 res => {
28 this.serverStats = res
29 }
30 )
31 }
32
33 getStats () {
34 return this.http
35 .get<ServerStats>(InstanceStatisticsComponent.BASE_STATS_URL)
36 } 21 }
37} 22}
diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.html b/client/src/app/shared/menu/top-menu-dropdown.component.html
index 35511ee62..d577f757d 100644
--- a/client/src/app/shared/menu/top-menu-dropdown.component.html
+++ b/client/src/app/shared/menu/top-menu-dropdown.component.html
@@ -1,12 +1,22 @@
1<div class="sub-menu"> 1<div class="sub-menu" [ngClass]="{ 'no-scroll': isModalOpened }">
2 <ng-container *ngFor="let menuEntry of menuEntries"> 2 <ng-container *ngFor="let menuEntry of menuEntries; index as id">
3 3
4 <a *ngIf="menuEntry.routerLink" [routerLink]="menuEntry.routerLink" routerLinkActive="active" class="title-page">{{ menuEntry.label }}</a> 4 <a *ngIf="menuEntry.routerLink" [routerLink]="menuEntry.routerLink" routerLinkActive="active" class="title-page title-page-settings">{{ menuEntry.label }}</a>
5 5
6 <div *ngIf="!menuEntry.routerLink" ngbDropdown [container]="container" class="parent-entry" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)"> 6 <div *ngIf="!menuEntry.routerLink" ngbDropdown [container]="container" class="parent-entry"
7 #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)">
7 <span 8 <span
9 *ngIf="isInSmallView"
10 [ngClass]="{ active: !!suffixLabels[menuEntry.label] }"
11 (click)="openModal(id)" role="button" class="title-page title-page-settings">
12 <ng-container i18n>{{ menuEntry.label }}</ng-container>
13 <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container>
14 </span>
15
16 <span
17 *ngIf="!isInSmallView"
8 (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor 18 (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor
9 (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page" 19 (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page title-page-settings"
10 > 20 >
11 <ng-container i18n>{{ menuEntry.label }}</ng-container> 21 <ng-container i18n>{{ menuEntry.label }}</ng-container>
12 <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container> 22 <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container>
@@ -20,6 +30,21 @@
20 </a> 30 </a>
21 </div> 31 </div>
22 </div> 32 </div>
23
24 </ng-container> 33 </ng-container>
25</div> 34</div>
35
36<ng-template #modal let-close="close" let-dismiss="dismiss">
37 <div class="modal-body">
38 <ng-container *ngFor="let menuEntry of menuEntries; index as id">
39 <div [ngClass]="{ hidden: id !== currentMenuEntryIndex }">
40 <a *ngFor="let menuChild of menuEntry.children"
41 [ngClass]="{ icon: hasIcons }"
42 [routerLink]="menuChild.routerLink" routerLinkActive="active" (click)="dismissOtherModals()">
43 <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName"></my-global-icon>
44
45 {{ menuChild.label }}
46 </a>
47 </div>
48 </ng-container>
49 </div>
50</ng-template>
diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.scss b/client/src/app/shared/menu/top-menu-dropdown.component.scss
index 1be699a88..5f90dcf80 100644
--- a/client/src/app/shared/menu/top-menu-dropdown.component.scss
+++ b/client/src/app/shared/menu/top-menu-dropdown.component.scss
@@ -25,3 +25,32 @@
25 25
26 top: -1px; 26 top: -1px;
27} 27}
28
29.sub-menu.no-scroll {
30 overflow-x: hidden;
31}
32
33.modal-body {
34 .hidden {
35 display: none;
36 }
37
38 a {
39 @include disable-default-a-behaviour;
40
41 color: currentColor;
42 box-sizing: border-box;
43 display: block;
44 font-size: 1.2rem;
45 padding: 9px 12px;
46 text-align: initial;
47 text-transform: unset;
48 width: 100%;
49
50 &.active {
51 color: var(--mainBackgroundColor) !important;
52 background-color: var(--mainHoverColor);
53 opacity: .9;
54 }
55 }
56}
diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.ts b/client/src/app/shared/menu/top-menu-dropdown.component.ts
index 5ccdafb54..f98240804 100644
--- a/client/src/app/shared/menu/top-menu-dropdown.component.ts
+++ b/client/src/app/shared/menu/top-menu-dropdown.component.ts
@@ -1,8 +1,14 @@
1import { Component, Input, OnDestroy, OnInit } from '@angular/core' 1import {
2 Component,
3 Input,
4 OnDestroy,
5 OnInit,
6 ViewChild
7} from '@angular/core'
2import { filter, take } from 'rxjs/operators' 8import { filter, take } from 'rxjs/operators'
3import { NavigationEnd, Router } from '@angular/router' 9import { NavigationEnd, Router } from '@angular/router'
4import { Subscription } from 'rxjs' 10import { Subscription } from 'rxjs'
5import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' 11import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap'
6import { GlobalIconName } from '@app/shared/images/global-icon.component' 12import { GlobalIconName } from '@app/shared/images/global-icon.component'
7import { ScreenService } from '@app/shared/misc/screen.service' 13import { ScreenService } from '@app/shared/misc/screen.service'
8 14
@@ -26,32 +32,40 @@ export type TopMenuDropdownParam = {
26export class TopMenuDropdownComponent implements OnInit, OnDestroy { 32export class TopMenuDropdownComponent implements OnInit, OnDestroy {
27 @Input() menuEntries: TopMenuDropdownParam[] = [] 33 @Input() menuEntries: TopMenuDropdownParam[] = []
28 34
35 @ViewChild('modal', { static: true }) modal: NgbModal
36
29 suffixLabels: { [ parentLabel: string ]: string } 37 suffixLabels: { [ parentLabel: string ]: string }
30 hasIcons = false 38 hasIcons = false
31 container: undefined | 'body' = undefined 39 container: undefined | 'body' = undefined
40 isModalOpened = false
41 currentMenuEntryIndex: number
32 42
33 private openedOnHover = false 43 private openedOnHover = false
34 private routeSub: Subscription 44 private routeSub: Subscription
35 45
36 constructor ( 46 constructor (
37 private router: Router, 47 private router: Router,
48 private modalService: NgbModal,
38 private screen: ScreenService 49 private screen: ScreenService
39 ) {} 50 ) { }
51
52 get isInSmallView () {
53 return this.screen.isInSmallView()
54 }
40 55
41 ngOnInit () { 56 ngOnInit () {
42 this.updateChildLabels(window.location.pathname) 57 this.updateChildLabels(window.location.pathname)
43 58
44 this.routeSub = this.router.events 59 this.routeSub = this.router.events
45 .pipe(filter(event => event instanceof NavigationEnd)) 60 .pipe(filter(event => event instanceof NavigationEnd))
46 .subscribe(() => this.updateChildLabels(window.location.pathname)) 61 .subscribe(() => this.updateChildLabels(window.location.pathname))
47 62
48 this.hasIcons = this.menuEntries.some( 63 this.hasIcons = this.menuEntries.some(
49 e => e.children && e.children.some(c => !!c.iconName) 64 e => e.children && e.children.some(c => !!c.iconName)
50 ) 65 )
51 66
52 // FIXME: We have to set body for the container to avoid because of scroll overflow on mobile view 67 // We have to set body for the container to avoid scroll overflow on mobile and small views
53 // But this break our hovering system 68 if (this.isInSmallView) {
54 if (this.screen.isInMobileView()) {
55 this.container = 'body' 69 this.container = 'body'
56 } 70 }
57 } 71 }
@@ -86,6 +100,27 @@ export class TopMenuDropdownComponent implements OnInit, OnDestroy {
86 this.openedOnHover = false 100 this.openedOnHover = false
87 } 101 }
88 102
103 openModal (index: number) {
104 this.currentMenuEntryIndex = index
105 this.isModalOpened = true
106
107 this.modalService.open(this.modal, {
108 centered: true,
109 beforeDismiss: async () => {
110 this.onModalDismiss()
111 return true
112 }
113 })
114 }
115
116 onModalDismiss () {
117 this.isModalOpened = false
118 }
119
120 dismissOtherModals () {
121 this.modalService.dismissAll()
122 }
123
89 private updateChildLabels (path: string) { 124 private updateChildLabels (path: string) {
90 this.suffixLabels = {} 125 this.suffixLabels = {}
91 126
diff --git a/client/src/app/shared/misc/help.component.scss b/client/src/app/shared/misc/help.component.scss
index f55a516e4..3c8b66cd5 100644
--- a/client/src/app/shared/misc/help.component.scss
+++ b/client/src/app/shared/misc/help.component.scss
@@ -17,6 +17,7 @@
17 17
18::ng-deep { 18::ng-deep {
19 .help-popover { 19 .help-popover {
20 z-index: z(help-popover) !important;
20 max-width: 300px; 21 max-width: 300px;
21 22
22 .popover-body { 23 .popover-body {
@@ -26,7 +27,7 @@
26 font-size: 13px; 27 font-size: 13px;
27 background-color: var(--mainBackgroundColor); 28 background-color: var(--mainBackgroundColor);
28 color: var(--mainForegroundColor); 29 color: var(--mainForegroundColor);
29 box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2); 30 border-radius: 3px;
30 31
31 p { 32 p {
32 margin-bottom: 0; 33 margin-bottom: 0;
diff --git a/client/src/app/shared/misc/list-overflow.component.html b/client/src/app/shared/misc/list-overflow.component.html
new file mode 100644
index 000000000..986572801
--- /dev/null
+++ b/client/src/app/shared/misc/list-overflow.component.html
@@ -0,0 +1,35 @@
1<div #itemsParent class="d-flex align-items-center text-nowrap w-100 list-overflow-parent">
2 <span [id]="getId(id)" #itemsRendered *ngFor="let item of items; index as id">
3 <ng-container *ngTemplateOutlet="itemTemplate; context: {item: item}"></ng-container>
4 </span>
5
6 <ng-container *ngIf="isMenuDisplayed()">
7 <button *ngIf="isInMobileView" class="btn btn-outline-secondary btn-sm list-overflow-menu" (click)="toggleModal()">
8 <span class="glyphicon glyphicon-chevron-down"></span>
9 </button>
10
11 <div *ngIf="!isInMobileView" class="list-overflow-menu" ngbDropdown container="body" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)" (mouseenter)="openDropdownOnHover(dropdown)">
12 <button class="btn btn-outline-secondary btn-sm" [ngClass]="{ routeActive: active }"
13 ngbDropdownAnchor (click)="dropdownAnchorClicked(dropdown)" role="button"
14 >
15 <span class="glyphicon glyphicon-chevron-down"></span>
16 </button>
17
18 <div ngbDropdownMenu>
19 <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
20 [routerLink]="item.routerLink" routerLinkActive="active" class="dropdown-item">
21 {{ item.label }}
22 </a>
23 </div>
24 </div>
25 </ng-container>
26</div >
27
28<ng-template #modal let-close="close" let-dismiss="dismiss">
29 <div class="modal-body">
30 <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
31 [routerLink]="item.routerLink" routerLinkActive="active" (click)="dismissOtherModals()">
32 {{ item.label }}
33 </a>
34 </div>
35</ng-template>
diff --git a/client/src/app/shared/misc/list-overflow.component.scss b/client/src/app/shared/misc/list-overflow.component.scss
new file mode 100644
index 000000000..1e5fe4c10
--- /dev/null
+++ b/client/src/app/shared/misc/list-overflow.component.scss
@@ -0,0 +1,61 @@
1@import '_mixins';
2
3:host {
4 width: 100%;
5}
6
7.list-overflow-parent {
8 overflow: hidden;
9}
10
11.list-overflow-menu {
12 position: absolute;
13 right: 25px;
14}
15
16button {
17 width: 30px;
18 border: none;
19
20 &::after {
21 display: none;
22 }
23
24 &.routeActive {
25 &::after {
26 display: inherit;
27 border: 2px solid var(--mainColor);
28 position: relative;
29 right: 95%;
30 top: 50%;
31 }
32 }
33}
34
35::ng-deep .dropdown-menu {
36 margin-top: 0 !important;
37 position: static;
38 right: auto;
39 bottom: auto
40}
41
42.modal-body {
43 a {
44 @include disable-default-a-behaviour;
45
46 color: currentColor;
47 box-sizing: border-box;
48 display: block;
49 font-size: 1.2rem;
50 padding: 9px 12px;
51 text-align: initial;
52 text-transform: unset;
53 width: 100%;
54
55 &.active {
56 color: var(--mainBackgroundColor) !important;
57 background-color: var(--mainHoverColor);
58 opacity: .9;
59 }
60 }
61}
diff --git a/client/src/app/shared/misc/list-overflow.component.ts b/client/src/app/shared/misc/list-overflow.component.ts
new file mode 100644
index 000000000..30f43ba43
--- /dev/null
+++ b/client/src/app/shared/misc/list-overflow.component.ts
@@ -0,0 +1,120 @@
1import {
2 AfterViewInit,
3 ChangeDetectionStrategy,
4 ChangeDetectorRef,
5 Component,
6 ElementRef,
7 HostListener,
8 Input,
9 QueryList,
10 TemplateRef,
11 ViewChild,
12 ViewChildren
13} from '@angular/core'
14import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap'
15import { lowerFirst, uniqueId } from 'lodash-es'
16import { ScreenService } from './screen.service'
17import { take } from 'rxjs/operators'
18
19export interface ListOverflowItem {
20 label: string
21 routerLink: string | any[]
22}
23
24@Component({
25 selector: 'list-overflow',
26 templateUrl: './list-overflow.component.html',
27 styleUrls: [ './list-overflow.component.scss' ],
28 changeDetection: ChangeDetectionStrategy.OnPush
29})
30export class ListOverflowComponent<T extends ListOverflowItem> implements AfterViewInit {
31 @Input() items: T[]
32 @Input() itemTemplate: TemplateRef<{item: T}>
33
34 @ViewChild('modal', { static: true }) modal: ElementRef
35 @ViewChild('itemsParent', { static: true }) parent: ElementRef<HTMLDivElement>
36 @ViewChildren('itemsRendered') itemsRendered: QueryList<ElementRef>
37
38 showItemsUntilIndexExcluded: number
39 active = false
40 isInTouchScreen = false
41 isInMobileView = false
42
43 private openedOnHover = false
44
45 constructor (
46 private cdr: ChangeDetectorRef,
47 private modalService: NgbModal,
48 private screenService: ScreenService
49 ) {}
50
51 ngAfterViewInit () {
52 setTimeout(() => this.onWindowResize(), 0)
53 }
54
55 isMenuDisplayed () {
56 return !!this.showItemsUntilIndexExcluded
57 }
58
59 @HostListener('window:resize')
60 onWindowResize () {
61 this.isInTouchScreen = !!this.screenService.isInTouchScreen()
62 this.isInMobileView = !!this.screenService.isInMobileView()
63
64 const parentWidth = this.parent.nativeElement.getBoundingClientRect().width
65 let showItemsUntilIndexExcluded: number
66 let accWidth = 0
67
68 for (const [index, el] of this.itemsRendered.toArray().entries()) {
69 accWidth += el.nativeElement.getBoundingClientRect().width
70 if (showItemsUntilIndexExcluded === undefined) {
71 showItemsUntilIndexExcluded = (parentWidth < accWidth) ? index : undefined
72 }
73
74 const e = document.getElementById(this.getId(index))
75 const shouldBeVisible = showItemsUntilIndexExcluded ? index < showItemsUntilIndexExcluded : true
76 e.style.visibility = shouldBeVisible ? 'inherit' : 'hidden'
77 }
78
79 this.showItemsUntilIndexExcluded = showItemsUntilIndexExcluded
80 this.cdr.markForCheck()
81 }
82
83 openDropdownOnHover (dropdown: NgbDropdown) {
84 this.openedOnHover = true
85 dropdown.open()
86
87 // Menu was closed
88 dropdown.openChange
89 .pipe(take(1))
90 .subscribe(() => this.openedOnHover = false)
91 }
92
93 dropdownAnchorClicked (dropdown: NgbDropdown) {
94 if (this.openedOnHover) {
95 this.openedOnHover = false
96 return
97 }
98
99 return dropdown.toggle()
100 }
101
102 closeDropdownIfHovered (dropdown: NgbDropdown) {
103 if (this.openedOnHover === false) return
104
105 dropdown.close()
106 this.openedOnHover = false
107 }
108
109 toggleModal () {
110 this.modalService.open(this.modal, { centered: true })
111 }
112
113 dismissOtherModals () {
114 this.modalService.dismissAll()
115 }
116
117 getId (id: number | string = uniqueId()): string {
118 return lowerFirst(this.constructor.name) + '_' + id
119 }
120}
diff --git a/client/src/app/shared/misc/screen.service.ts b/client/src/app/shared/misc/screen.service.ts
index 220d41d59..9c71a8c83 100644
--- a/client/src/app/shared/misc/screen.service.ts
+++ b/client/src/app/shared/misc/screen.service.ts
@@ -14,6 +14,10 @@ export class ScreenService {
14 return this.getWindowInnerWidth() < 800 14 return this.getWindowInnerWidth() < 800
15 } 15 }
16 16
17 isInMediumView () {
18 return this.getWindowInnerWidth() < 1100
19 }
20
17 isInMobileView () { 21 isInMobileView () {
18 return this.getWindowInnerWidth() < 500 22 return this.getWindowInnerWidth() < 500
19 } 23 }
diff --git a/client/src/app/shared/misc/storage.service.ts b/client/src/app/shared/misc/storage.service.ts
new file mode 100644
index 000000000..0d4a8ab53
--- /dev/null
+++ b/client/src/app/shared/misc/storage.service.ts
@@ -0,0 +1,40 @@
1import { Injectable } from '@angular/core'
2import { Observable, Subject } from 'rxjs'
3import {
4 peertubeLocalStorage,
5 peertubeSessionStorage
6} from './peertube-web-storage'
7import { filter } from 'rxjs/operators'
8
9abstract class StorageService {
10 protected instance: Storage
11 static storageSub = new Subject<string>()
12
13 watch (keys?: string[]): Observable<string> {
14 return StorageService.storageSub.asObservable().pipe(filter(val => keys ? keys.includes(val) : true))
15 }
16
17 getItem (key: string) {
18 return this.instance.getItem(key)
19 }
20
21 setItem (key: string, data: any, notifyOfUpdate = true) {
22 this.instance.setItem(key, data)
23 if (notifyOfUpdate) StorageService.storageSub.next(key)
24 }
25
26 removeItem (key: string, notifyOfUpdate = true) {
27 this.instance.removeItem(key)
28 if (notifyOfUpdate) StorageService.storageSub.next(key)
29 }
30}
31
32@Injectable()
33export class LocalStorageService extends StorageService {
34 protected instance: Storage = peertubeLocalStorage
35}
36
37@Injectable()
38export class SessionStorageService extends StorageService {
39 protected instance: Storage = peertubeSessionStorage
40}
diff --git a/client/src/app/shared/moderation/user-ban-modal.component.html b/client/src/app/shared/moderation/user-ban-modal.component.html
index f38ea543d..365eb1938 100644
--- a/client/src/app/shared/moderation/user-ban-modal.component.html
+++ b/client/src/app/shared/moderation/user-ban-modal.component.html
@@ -8,8 +8,10 @@
8 <div class="modal-body"> 8 <div class="modal-body">
9 <form novalidate [formGroup]="form" (ngSubmit)="banUser()"> 9 <form novalidate [formGroup]="form" (ngSubmit)="banUser()">
10 <div class="form-group"> 10 <div class="form-group">
11 <textarea i18n-placeholder placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }"> 11 <textarea
12 </textarea> 12 i18n-placeholder placeholder="Reason..." formControlName="reason"
13 class="form-control" [ngClass]="{ 'input-error': formErrors['reason'] }"
14 ></textarea>
13 <div *ngIf="formErrors.reason" class="form-error"> 15 <div *ngIf="formErrors.reason" class="form-error">
14 {{ formErrors.reason }} 16 {{ formErrors.reason }}
15 </div> 17 </div>
@@ -20,7 +22,10 @@
20 </div> 22 </div>
21 23
22 <div class="form-group inputs"> 24 <div class="form-group inputs">
23 <span i18n class="action-button action-button-cancel" (click)="hide()">Cancel</span> 25 <input
26 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
27 (click)="hide()" (key.enter)="hide()"
28 >
24 29
25 <input 30 <input
26 type="submit" i18n-value value="Ban this user" class="action-button-submit" 31 type="submit" i18n-value value="Ban this user" class="action-button-submit"
diff --git a/client/src/app/shared/moderation/user-ban-modal.component.ts b/client/src/app/shared/moderation/user-ban-modal.component.ts
index cf0e1577a..1647e3691 100644
--- a/client/src/app/shared/moderation/user-ban-modal.component.ts
+++ b/client/src/app/shared/moderation/user-ban-modal.component.ts
@@ -39,7 +39,7 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
39 39
40 openModal (user: User | User[]) { 40 openModal (user: User | User[]) {
41 this.usersToBan = user 41 this.usersToBan = user
42 this.openedModal = this.modalService.open(this.modal) 42 this.openedModal = this.modalService.open(this.modal, { centered: true })
43 } 43 }
44 44
45 hide () { 45 hide () {
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
index 11d8588f4..c8ccaa800 100644
--- a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
+++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
@@ -14,13 +14,13 @@ import { ServerConfig } from '@shared/models'
14 templateUrl: './user-moderation-dropdown.component.html' 14 templateUrl: './user-moderation-dropdown.component.html'
15}) 15})
16export class UserModerationDropdownComponent implements OnInit, OnChanges { 16export class UserModerationDropdownComponent implements OnInit, OnChanges {
17 @ViewChild('userBanModal', { static: false }) userBanModal: UserBanModalComponent 17 @ViewChild('userBanModal') userBanModal: UserBanModalComponent
18 18
19 @Input() user: User 19 @Input() user: User
20 @Input() account: Account 20 @Input() account: Account
21 21
22 @Input() buttonSize: 'normal' | 'small' = 'normal' 22 @Input() buttonSize: 'normal' | 'small' = 'normal'
23 @Input() placement = 'left' 23 @Input() placement = 'left-top left-bottom auto'
24 @Input() label: string 24 @Input() label: string
25 25
26 @Output() userChanged = new EventEmitter() 26 @Output() userChanged = new EventEmitter()
diff --git a/client/src/app/shared/overview/overview.service.ts b/client/src/app/shared/overview/overview.service.ts
index 79cb781f7..6d8af8052 100644
--- a/client/src/app/shared/overview/overview.service.ts
+++ b/client/src/app/shared/overview/overview.service.ts
@@ -1,5 +1,5 @@
1import { catchError, map, switchMap, tap } from 'rxjs/operators' 1import { catchError, map, switchMap, tap } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http' 2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { forkJoin, Observable, of } from 'rxjs' 4import { forkJoin, Observable, of } from 'rxjs'
5import { VideosOverview as VideosOverviewServer, peertubeTranslate } from '../../../../../shared/models' 5import { VideosOverview as VideosOverviewServer, peertubeTranslate } from '../../../../../shared/models'
@@ -21,9 +21,12 @@ export class OverviewService {
21 private serverService: ServerService 21 private serverService: ServerService
22 ) {} 22 ) {}
23 23
24 getVideosOverview (): Observable<VideosOverview> { 24 getVideosOverview (page: number): Observable<VideosOverview> {
25 let params = new HttpParams()
26 params = params.append('page', page + '')
27
25 return this.authHttp 28 return this.authHttp
26 .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos') 29 .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos', { params })
27 .pipe( 30 .pipe(
28 switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)), 31 switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)),
29 catchError(err => this.restExtractor.handleError(err)) 32 catchError(err => this.restExtractor.handleError(err))
diff --git a/client/src/app/shared/renderer/html-renderer.service.ts b/client/src/app/shared/renderer/html-renderer.service.ts
index 94a8aa4c6..1ddd8fe2f 100644
--- a/client/src/app/shared/renderer/html-renderer.service.ts
+++ b/client/src/app/shared/renderer/html-renderer.service.ts
@@ -19,15 +19,18 @@ export class HtmlRendererService {
19 allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ], 19 allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
20 allowedSchemes: [ 'http', 'https' ], 20 allowedSchemes: [ 'http', 'https' ],
21 allowedAttributes: { 21 allowedAttributes: {
22 'a': [ 'href', 'class', 'target' ] 22 'a': [ 'href', 'class', 'target', 'rel' ]
23 }, 23 },
24 transformTags: { 24 transformTags: {
25 a: (tagName, attribs) => { 25 a: (tagName, attribs) => {
26 let rel = 'noopener noreferrer'
27 if (attribs.rel === 'me') rel += ' me'
28
26 return { 29 return {
27 tagName, 30 tagName,
28 attribs: Object.assign(attribs, { 31 attribs: Object.assign(attribs, {
29 target: '_blank', 32 target: '_blank',
30 rel: 'noopener noreferrer' 33 rel
31 }) 34 })
32 } 35 }
33 } 36 }
diff --git a/client/src/app/shared/renderer/markdown.service.ts b/client/src/app/shared/renderer/markdown.service.ts
index 0d3fde537..f0c87326f 100644
--- a/client/src/app/shared/renderer/markdown.service.ts
+++ b/client/src/app/shared/renderer/markdown.service.ts
@@ -1,7 +1,7 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { MarkdownIt } from 'markdown-it'
3import { buildVideoLink } from '../../../assets/player/utils' 2import { buildVideoLink } from '../../../assets/player/utils'
4import { HtmlRendererService } from '@app/shared/renderer/html-renderer.service' 3import { HtmlRendererService } from '@app/shared/renderer/html-renderer.service'
4import * as MarkdownIt from 'markdown-it'
5 5
6type MarkdownParsers = { 6type MarkdownParsers = {
7 textMarkdownIt: MarkdownIt 7 textMarkdownIt: MarkdownIt
@@ -100,7 +100,7 @@ export class MarkdownService {
100 } 100 }
101 101
102 private async createMarkdownIt (config: MarkdownConfig) { 102 private async createMarkdownIt (config: MarkdownConfig) {
103 // FIXME: import('...') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function 103 // FIXME: import('...') returns a struct module, containing a "default" field
104 const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default 104 const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default
105 105
106 const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html }) 106 const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html })
diff --git a/client/src/app/shared/rest/rest-table.ts b/client/src/app/shared/rest/rest-table.ts
index c180346af..d4e6cf5f2 100644
--- a/client/src/app/shared/rest/rest-table.ts
+++ b/client/src/app/shared/rest/rest-table.ts
@@ -1,6 +1,5 @@
1import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' 1import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
2import { LazyLoadEvent } from 'primeng/components/common/lazyloadevent' 2import { LazyLoadEvent, SortMeta } from 'primeng/api'
3import { SortMeta } from 'primeng/components/common/sortmeta'
4import { RestPagination } from './rest-pagination' 3import { RestPagination } from './rest-pagination'
5import { Subject } from 'rxjs' 4import { Subject } from 'rxjs'
6import { debounceTime, distinctUntilChanged } from 'rxjs/operators' 5import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
@@ -8,13 +7,17 @@ import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
8export abstract class RestTable { 7export abstract class RestTable {
9 8
10 abstract totalRecords: number 9 abstract totalRecords: number
11 abstract rowsPerPage: number
12 abstract sort: SortMeta 10 abstract sort: SortMeta
13 abstract pagination: RestPagination 11 abstract pagination: RestPagination
14 12
15 protected search: string 13 search: string
14 rowsPerPageOptions = [ 10, 20, 50, 100 ]
15 rowsPerPage = this.rowsPerPageOptions[0]
16 expandedRows = {}
17
16 private searchStream: Subject<string> 18 private searchStream: Subject<string>
17 private sortLocalStorageKey = 'rest-table-sort-' + this.constructor.name 19
20 abstract getIdentifier (): string
18 21
19 initialize () { 22 initialize () {
20 this.loadSort() 23 this.loadSort()
@@ -22,13 +25,13 @@ export abstract class RestTable {
22 } 25 }
23 26
24 loadSort () { 27 loadSort () {
25 const result = peertubeLocalStorage.getItem(this.sortLocalStorageKey) 28 const result = peertubeLocalStorage.getItem(this.getSortLocalStorageKey())
26 29
27 if (result) { 30 if (result) {
28 try { 31 try {
29 this.sort = JSON.parse(result) 32 this.sort = JSON.parse(result)
30 } catch (err) { 33 } catch (err) {
31 console.error('Cannot load sort of local storage key ' + this.sortLocalStorageKey, err) 34 console.error('Cannot load sort of local storage key ' + this.getSortLocalStorageKey(), err)
32 } 35 }
33 } 36 }
34 } 37 }
@@ -49,7 +52,7 @@ export abstract class RestTable {
49 } 52 }
50 53
51 saveSort () { 54 saveSort () {
52 peertubeLocalStorage.setItem(this.sortLocalStorageKey, JSON.stringify(this.sort)) 55 peertubeLocalStorage.setItem(this.getSortLocalStorageKey(), JSON.stringify(this.sort))
53 } 56 }
54 57
55 initSearch () { 58 initSearch () {
@@ -66,9 +69,37 @@ export abstract class RestTable {
66 }) 69 })
67 } 70 }
68 71
69 onSearch (search: string) { 72 onSearch (event: Event) {
70 this.searchStream.next(search) 73 const target = event.target as HTMLInputElement
74 this.searchStream.next(target.value)
75 }
76
77 onPage (event: { first: number, rows: number }) {
78 if (this.rowsPerPage !== event.rows) {
79 this.rowsPerPage = event.rows
80 this.pagination = {
81 start: event.first,
82 count: this.rowsPerPage
83 }
84 this.loadData()
85 }
86 this.expandedRows = {}
87 }
88
89 setTableFilter (filter: string) {
90 // FIXME: cannot use ViewChild, so create a component for the filter input
91 const filterInput = document.getElementById('table-filter') as HTMLInputElement
92 if (filterInput) filterInput.value = filter
93 }
94
95 resetSearch () {
96 this.searchStream.next('')
97 this.setTableFilter('')
71 } 98 }
72 99
73 protected abstract loadData (): void 100 protected abstract loadData (): void
101
102 private getSortLocalStorageKey () {
103 return 'rest-table-sort-' + this.getIdentifier()
104 }
74} 105}
diff --git a/client/src/app/shared/rest/rest.service.ts b/client/src/app/shared/rest/rest.service.ts
index 16bb6d82c..cd6db1f3c 100644
--- a/client/src/app/shared/rest/rest.service.ts
+++ b/client/src/app/shared/rest/rest.service.ts
@@ -1,10 +1,21 @@
1import { Injectable } from '@angular/core' 1import { SortMeta } from 'primeng/api'
2import { HttpParams } from '@angular/common/http' 2import { HttpParams } from '@angular/common/http'
3import { SortMeta } from 'primeng/components/common/sortmeta' 3import { Injectable } from '@angular/core'
4import { ComponentPagination, ComponentPaginationLight } from './component-pagination.model' 4import { ComponentPaginationLight } from './component-pagination.model'
5
6import { RestPagination } from './rest-pagination' 5import { RestPagination } from './rest-pagination'
7 6
7interface QueryStringFilterPrefixes {
8 [key: string]: {
9 prefix: string
10 handler?: (v: string) => string | number
11 multiple?: boolean
12 }
13}
14
15type ParseQueryStringFilterResult = {
16 [key: string]: string | number | (string | number)[]
17}
18
8@Injectable() 19@Injectable()
9export class RestService { 20export class RestService {
10 21
@@ -53,4 +64,48 @@ export class RestService {
53 64
54 return { start, count } 65 return { start, count }
55 } 66 }
67
68 parseQueryStringFilter (q: string, prefixes: QueryStringFilterPrefixes): ParseQueryStringFilterResult {
69 if (!q) return {}
70
71 // Tokenize the strings using spaces
72 const tokens = q.split(' ').filter(token => !!token)
73
74 // Build prefix array
75 const prefixeStrings = Object.values(prefixes)
76 .map(p => p.prefix)
77
78 // Search is the querystring minus defined filters
79 const searchTokens = tokens.filter(t => {
80 return prefixeStrings.every(prefixString => t.startsWith(prefixString) === false)
81 })
82
83 const additionalFilters: ParseQueryStringFilterResult = {}
84
85 for (const prefixKey of Object.keys(prefixes)) {
86 const prefixObj = prefixes[prefixKey]
87 const prefix = prefixObj.prefix
88
89 const matchedTokens = tokens.filter(t => t.startsWith(prefix))
90 .map(t => t.slice(prefix.length)) // Keep the value filter
91 .map(t => {
92 if (prefixObj.handler) return prefixObj.handler(t)
93
94 return t
95 })
96 .filter(t => !!t)
97
98 if (matchedTokens.length === 0) continue
99
100 additionalFilters[prefixKey] = prefixObj.multiple === true
101 ? matchedTokens
102 : matchedTokens[0]
103 }
104
105 return {
106 search: searchTokens.join(' '),
107
108 ...additionalFilters
109 }
110 }
56} 111}
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index b2eb13f73..01735c187 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -5,9 +5,10 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
5import { RouterModule } from '@angular/router' 5import { RouterModule } from '@angular/router'
6import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component' 6import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component'
7import { HelpComponent } from '@app/shared/misc/help.component' 7import { HelpComponent } from '@app/shared/misc/help.component'
8import { ListOverflowComponent } from '@app/shared/misc/list-overflow.component'
8import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' 9import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
9import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' 10import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
10import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' 11import { SharedModule as PrimeSharedModule } from 'primeng/api'
11import { AUTH_INTERCEPTOR_PROVIDER } from './auth' 12import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
12import { ButtonComponent } from './buttons/button.component' 13import { ButtonComponent } from './buttons/button.component'
13import { DeleteButtonComponent } from './buttons/delete-button.component' 14import { DeleteButtonComponent } from './buttons/delete-button.component'
@@ -46,6 +47,7 @@ import {
46import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' 47import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
47import { InputMaskModule } from 'primeng/inputmask' 48import { InputMaskModule } from 'primeng/inputmask'
48import { ScreenService } from '@app/shared/misc/screen.service' 49import { ScreenService } from '@app/shared/misc/screen.service'
50import { LocalStorageService, SessionStorageService } from '@app/shared/misc/storage.service'
49import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' 51import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
50import { VideoCaptionService } from '@app/shared/video-caption' 52import { VideoCaptionService } from '@app/shared/video-caption'
51import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component' 53import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component'
@@ -56,7 +58,7 @@ import {
56 NgbDropdownModule, 58 NgbDropdownModule,
57 NgbModalModule, 59 NgbModalModule,
58 NgbPopoverModule, 60 NgbPopoverModule,
59 NgbTabsetModule, 61 NgbNavModule,
60 NgbTooltipModule 62 NgbTooltipModule
61} from '@ng-bootstrap/ng-bootstrap' 63} from '@ng-bootstrap/ng-bootstrap'
62import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription' 64import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription'
@@ -88,16 +90,24 @@ import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
88import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe' 90import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe'
89import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe' 91import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe'
90import { FromNowPipe } from '@app/shared/angular/from-now.pipe' 92import { FromNowPipe } from '@app/shared/angular/from-now.pipe'
93import { HighlightPipe } from '@app/shared/angular/highlight.pipe'
91import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' 94import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
92import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component' 95import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component'
93import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component' 96import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component'
94import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component' 97import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component'
95import { VideoReportComponent } from '@app/shared/video/modals/video-report.component' 98import { VideoReportComponent } from '@app/shared/video/modals/video-report.component'
96import { ClipboardModule } from 'ngx-clipboard'
97import { FollowService } from '@app/shared/instance/follow.service' 99import { FollowService } from '@app/shared/instance/follow.service'
98import { MultiSelectModule } from 'primeng/multiselect' 100import { MultiSelectModule } from 'primeng/multiselect'
99import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component' 101import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component'
100import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component' 102import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component'
103import { RedundancyService } from '@app/shared/video/redundancy.service'
104import { ClipboardModule } from '@angular/cdk/clipboard'
105import { InputSwitchModule } from 'primeng/inputswitch'
106
107import { MyAccountVideoSettingsComponent } from '@app/+my-account/my-account-settings/my-account-video-settings'
108import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account-settings/my-account-interface'
109import { ActorAvatarInfoComponent } from '@app/+my-account/shared/actor-avatar-info.component'
110import { BatchDomainsValidatorsService } from '@app/+admin/config/shared/batch-domains-validators.service'
101 111
102@NgModule({ 112@NgModule({
103 imports: [ 113 imports: [
@@ -110,7 +120,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
110 NgbDropdownModule, 120 NgbDropdownModule,
111 NgbModalModule, 121 NgbModalModule,
112 NgbPopoverModule, 122 NgbPopoverModule,
113 NgbTabsetModule, 123 NgbNavModule,
114 NgbTooltipModule, 124 NgbTooltipModule,
115 NgbCollapseModule, 125 NgbCollapseModule,
116 126
@@ -119,7 +129,8 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
119 PrimeSharedModule, 129 PrimeSharedModule,
120 InputMaskModule, 130 InputMaskModule,
121 NgPipesModule, 131 NgPipesModule,
122 MultiSelectModule 132 MultiSelectModule,
133 InputSwitchModule
123 ], 134 ],
124 135
125 declarations: [ 136 declarations: [
@@ -147,6 +158,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
147 NumberFormatterPipe, 158 NumberFormatterPipe,
148 ObjectLengthPipe, 159 ObjectLengthPipe,
149 FromNowPipe, 160 FromNowPipe,
161 HighlightPipe,
150 PeerTubeTemplateDirective, 162 PeerTubeTemplateDirective,
151 VideoDurationPipe, 163 VideoDurationPipe,
152 164
@@ -155,6 +167,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
155 InfiniteScrollerDirective, 167 InfiniteScrollerDirective,
156 TextareaAutoResizeDirective, 168 TextareaAutoResizeDirective,
157 HelpComponent, 169 HelpComponent,
170 ListOverflowComponent,
158 171
159 ReactiveFileComponent, 172 ReactiveFileComponent,
160 PeertubeCheckboxComponent, 173 PeertubeCheckboxComponent,
@@ -175,7 +188,11 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
175 DateToggleComponent, 188 DateToggleComponent,
176 189
177 GlobalIconComponent, 190 GlobalIconComponent,
178 PreviewUploadComponent 191 PreviewUploadComponent,
192
193 MyAccountVideoSettingsComponent,
194 MyAccountInterfaceSettingsComponent,
195 ActorAvatarInfoComponent
179 ], 196 ],
180 197
181 exports: [ 198 exports: [
@@ -188,7 +205,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
188 NgbDropdownModule, 205 NgbDropdownModule,
189 NgbModalModule, 206 NgbModalModule,
190 NgbPopoverModule, 207 NgbPopoverModule,
191 NgbTabsetModule, 208 NgbNavModule,
192 NgbTooltipModule, 209 NgbTooltipModule,
193 NgbCollapseModule, 210 NgbCollapseModule,
194 211
@@ -226,6 +243,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
226 InfiniteScrollerDirective, 243 InfiniteScrollerDirective,
227 TextareaAutoResizeDirective, 244 TextareaAutoResizeDirective,
228 HelpComponent, 245 HelpComponent,
246 ListOverflowComponent,
229 InputReadonlyCopyComponent, 247 InputReadonlyCopyComponent,
230 248
231 ReactiveFileComponent, 249 ReactiveFileComponent,
@@ -250,8 +268,13 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
250 NumberFormatterPipe, 268 NumberFormatterPipe,
251 ObjectLengthPipe, 269 ObjectLengthPipe,
252 FromNowPipe, 270 FromNowPipe,
271 HighlightPipe,
253 PeerTubeTemplateDirective, 272 PeerTubeTemplateDirective,
254 VideoDurationPipe 273 VideoDurationPipe,
274
275 MyAccountVideoSettingsComponent,
276 MyAccountInterfaceSettingsComponent,
277 ActorAvatarInfoComponent
255 ], 278 ],
256 279
257 providers: [ 280 providers: [
@@ -275,6 +298,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
275 LoginValidatorsService, 298 LoginValidatorsService,
276 ResetPasswordValidatorsService, 299 ResetPasswordValidatorsService,
277 UserValidatorsService, 300 UserValidatorsService,
301 BatchDomainsValidatorsService,
278 VideoPlaylistValidatorsService, 302 VideoPlaylistValidatorsService,
279 VideoAbuseValidatorsService, 303 VideoAbuseValidatorsService,
280 VideoChannelValidatorsService, 304 VideoChannelValidatorsService,
@@ -296,10 +320,12 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
296 320
297 I18nPrimengCalendarService, 321 I18nPrimengCalendarService,
298 ScreenService, 322 ScreenService,
323 LocalStorageService, SessionStorageService,
299 324
300 UserNotificationService, 325 UserNotificationService,
301 326
302 FollowService, 327 FollowService,
328 RedundancyService,
303 329
304 I18n 330 I18n
305 ] 331 ]
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.html b/client/src/app/shared/user-subscription/subscribe-button.component.html
index f08c88f3c..85b3d1fdb 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.html
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.html
@@ -55,7 +55,7 @@
55 </button> 55 </button>
56 56
57 <button class="dropdown-item dropdown-item-neutral" i18n>Subscribe with a Mastodon account:</button> 57 <button class="dropdown-item dropdown-item-neutral" i18n>Subscribe with a Mastodon account:</button>
58 <my-remote-subscribe showHelp="true" [uri]="uri"></my-remote-subscribe> 58 <my-remote-subscribe [showHelp]="true" [uri]="uri"></my-remote-subscribe>
59 59
60 <div class="dropdown-divider"></div> 60 <div class="dropdown-divider"></div>
61 61
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.scss b/client/src/app/shared/user-subscription/subscribe-button.component.scss
index 114a12f06..b739c5ae2 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.scss
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.scss
@@ -13,9 +13,17 @@
13 font-size: 15px; 13 font-size: 15px;
14 } 14 }
15 15
16 &:not(.big) {
17 white-space: nowrap;
18 }
19
16 &.big { 20 &.big {
17 height: 35px; 21 height: 35px;
18 22
23 & > button:first-child {
24 width: 175px;
25 }
26
19 button .extra-text { 27 button .extra-text {
20 span:first-child { 28 span:first-child {
21 line-height: 80%; 29 line-height: 80%;
@@ -80,10 +88,6 @@
80 } 88 }
81 } 89 }
82 90
83 .dropdown-header {
84 padding-left: 1rem;
85 }
86
87 ::ng-deep form { 91 ::ng-deep form {
88 padding: 0.25rem 1rem; 92 padding: 0.25rem 1rem;
89 } 93 }
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts
index 7707d7dda..3348fe75f 100644
--- a/client/src/app/shared/users/user.model.ts
+++ b/client/src/app/shared/users/user.model.ts
@@ -1,10 +1,32 @@
1import { hasUserRight, User as UserServerModel, UserNotificationSetting, UserRight, UserRole, VideoChannel } from '../../../../../shared' 1import {
2 hasUserRight,
3 User as UserServerModel,
4 UserNotificationSetting,
5 UserRight,
6 UserRole
7} from '../../../../../shared/models/users'
8import { VideoChannel } from '../../../../../shared/models/videos'
2import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' 9import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type'
3import { Account } from '@app/shared/account/account.model' 10import { Account } from '@app/shared/account/account.model'
4import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 11import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
5import { UserAdminFlag } from '@shared/models/users/user-flag.model' 12import { UserAdminFlag } from '@shared/models/users/user-flag.model'
6 13
7export class User implements UserServerModel { 14export class User implements UserServerModel {
15 static KEYS = {
16 ID: 'id',
17 ROLE: 'role',
18 EMAIL: 'email',
19 VIDEOS_HISTORY_ENABLED: 'videos-history-enabled',
20 USERNAME: 'username',
21 NSFW_POLICY: 'nsfw_policy',
22 WEBTORRENT_ENABLED: 'peertube-videojs-' + 'webtorrent_enabled',
23 AUTO_PLAY_VIDEO: 'auto_play_video',
24 SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO: 'auto_play_next_video',
25 AUTO_PLAY_VIDEO_PLAYLIST: 'auto_play_video_playlist',
26 THEME: 'last_active_theme',
27 VIDEO_LANGUAGES: 'video_languages'
28 }
29
8 id: number 30 id: number
9 username: string 31 username: string
10 email: string 32 email: string
@@ -29,6 +51,11 @@ export class User implements UserServerModel {
29 videoQuotaDaily: number 51 videoQuotaDaily: number
30 videoQuotaUsed?: number 52 videoQuotaUsed?: number
31 videoQuotaUsedDaily?: number 53 videoQuotaUsedDaily?: number
54 videosCount?: number
55 videoAbusesCount?: number
56 videoAbusesAcceptedCount?: number
57 videoAbusesCreatedCount?: number
58 videoCommentsCount?: number
32 59
33 theme: string 60 theme: string
34 61
@@ -42,6 +69,10 @@ export class User implements UserServerModel {
42 noInstanceConfigWarningModal: boolean 69 noInstanceConfigWarningModal: boolean
43 noWelcomeModal: boolean 70 noWelcomeModal: boolean
44 71
72 pluginAuth: string | null
73
74 lastLoginDate: Date | null
75
45 createdAt: Date 76 createdAt: Date
46 77
47 constructor (hash: Partial<UserServerModel>) { 78 constructor (hash: Partial<UserServerModel>) {
@@ -57,11 +88,19 @@ export class User implements UserServerModel {
57 this.videoQuotaDaily = hash.videoQuotaDaily 88 this.videoQuotaDaily = hash.videoQuotaDaily
58 this.videoQuotaUsed = hash.videoQuotaUsed 89 this.videoQuotaUsed = hash.videoQuotaUsed
59 this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily 90 this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily
91 this.videosCount = hash.videosCount
92 this.videoAbusesCount = hash.videoAbusesCount
93 this.videoAbusesAcceptedCount = hash.videoAbusesAcceptedCount
94 this.videoAbusesCreatedCount = hash.videoAbusesCreatedCount
95 this.videoCommentsCount = hash.videoCommentsCount
60 96
61 this.nsfwPolicy = hash.nsfwPolicy 97 this.nsfwPolicy = hash.nsfwPolicy
62 this.webTorrentEnabled = hash.webTorrentEnabled 98 this.webTorrentEnabled = hash.webTorrentEnabled
63 this.videosHistoryEnabled = hash.videosHistoryEnabled
64 this.autoPlayVideo = hash.autoPlayVideo 99 this.autoPlayVideo = hash.autoPlayVideo
100 this.autoPlayNextVideo = hash.autoPlayNextVideo
101 this.autoPlayNextVideoPlaylist = hash.autoPlayNextVideoPlaylist
102 this.videosHistoryEnabled = hash.videosHistoryEnabled
103 this.videoLanguages = hash.videoLanguages
65 104
66 this.theme = hash.theme 105 this.theme = hash.theme
67 106
@@ -77,6 +116,9 @@ export class User implements UserServerModel {
77 116
78 this.createdAt = hash.createdAt 117 this.createdAt = hash.createdAt
79 118
119 this.pluginAuth = hash.pluginAuth
120 this.lastLoginDate = hash.lastLoginDate
121
80 if (hash.account !== undefined) { 122 if (hash.account !== undefined) {
81 this.account = new Account(hash.account) 123 this.account = new Account(hash.account)
82 } 124 }
diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts
index e24d91df3..abb4088b5 100644
--- a/client/src/app/shared/users/user.service.ts
+++ b/client/src/app/shared/users/user.service.ts
@@ -1,8 +1,8 @@
1import { from, Observable, of } from 'rxjs' 1import { from, Observable } from 'rxjs'
2import { catchError, concatMap, map, share, shareReplay, tap, toArray } from 'rxjs/operators' 2import { catchError, concatMap, map, shareReplay, toArray } from 'rxjs/operators'
3import { HttpClient, HttpParams } from '@angular/common/http' 3import { HttpClient, HttpParams } from '@angular/common/http'
4import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
5import { ResultList, User, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' 5import { ResultList, User as UserServerModel, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared'
6import { environment } from '../../../environments/environment' 6import { environment } from '../../../environments/environment'
7import { RestExtractor, RestPagination, RestService } from '../rest' 7import { RestExtractor, RestPagination, RestService } from '../rest'
8import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 8import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
@@ -10,6 +10,10 @@ import { SortMeta } from 'primeng/api'
10import { BytesPipe } from 'ngx-pipes' 10import { BytesPipe } from 'ngx-pipes'
11import { I18n } from '@ngx-translate/i18n-polyfill' 11import { I18n } from '@ngx-translate/i18n-polyfill'
12import { UserRegister } from '@shared/models/users/user-register.model' 12import { UserRegister } from '@shared/models/users/user-register.model'
13import { User } from './user.model'
14import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
15import { has } from 'lodash-es'
16import { LocalStorageService, SessionStorageService } from '../misc/storage.service'
13 17
14@Injectable() 18@Injectable()
15export class UserService { 19export class UserService {
@@ -17,12 +21,14 @@ export class UserService {
17 21
18 private bytesPipe = new BytesPipe() 22 private bytesPipe = new BytesPipe()
19 23
20 private userCache: { [ id: number ]: Observable<User> } = {} 24 private userCache: { [ id: number ]: Observable<UserServerModel> } = {}
21 25
22 constructor ( 26 constructor (
23 private authHttp: HttpClient, 27 private authHttp: HttpClient,
24 private restExtractor: RestExtractor, 28 private restExtractor: RestExtractor,
25 private restService: RestService, 29 private restService: RestService,
30 private localStorageService: LocalStorageService,
31 private sessionStorageService: SessionStorageService,
26 private i18n: I18n 32 private i18n: I18n
27 ) { } 33 ) { }
28 34
@@ -64,6 +70,30 @@ export class UserService {
64 ) 70 )
65 } 71 }
66 72
73 updateMyAnonymousProfile (profile: UserUpdateMe) {
74 const supportedKeys = {
75 // local storage keys
76 nsfwPolicy: (val: NSFWPolicyType) => this.localStorageService.setItem(User.KEYS.NSFW_POLICY, val),
77 webTorrentEnabled: (val: boolean) => this.localStorageService.setItem(User.KEYS.WEBTORRENT_ENABLED, String(val)),
78 autoPlayVideo: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO, String(val)),
79 autoPlayNextVideoPlaylist: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST, String(val)),
80 theme: (val: string) => this.localStorageService.setItem(User.KEYS.THEME, val),
81 videoLanguages: (val: string[]) => this.localStorageService.setItem(User.KEYS.VIDEO_LANGUAGES, JSON.stringify(val)),
82
83 // session storage keys
84 autoPlayNextVideo: (val: boolean) =>
85 this.sessionStorageService.setItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, String(val))
86 }
87
88 for (const key of Object.keys(profile)) {
89 try {
90 if (has(supportedKeys, key)) supportedKeys[key](profile[key])
91 } catch (err) {
92 console.error(`Cannot set item ${key} in localStorage. Likely due to a value impossible to stringify.`, err)
93 }
94 }
95 }
96
67 deleteMe () { 97 deleteMe () {
68 const url = UserService.BASE_USERS_URL + 'me' 98 const url = UserService.BASE_USERS_URL + 'me'
69 99
@@ -187,7 +217,7 @@ export class UserService {
187 ) 217 )
188 } 218 }
189 219
190 updateUsers (users: User[], userUpdate: UserUpdate) { 220 updateUsers (users: UserServerModel[], userUpdate: UserUpdate) {
191 return from(users) 221 return from(users)
192 .pipe( 222 .pipe(
193 concatMap(u => this.authHttp.put(UserService.BASE_USERS_URL + u.id, userUpdate)), 223 concatMap(u => this.authHttp.put(UserService.BASE_USERS_URL + u.id, userUpdate)),
@@ -204,18 +234,44 @@ export class UserService {
204 return this.userCache[userId] 234 return this.userCache[userId]
205 } 235 }
206 236
207 getUser (userId: number) { 237 getUser (userId: number, withStats = false) {
208 return this.authHttp.get<User>(UserService.BASE_USERS_URL + userId) 238 const params = new HttpParams().append('withStats', withStats + '')
239 return this.authHttp.get<UserServerModel>(UserService.BASE_USERS_URL + userId, { params })
209 .pipe(catchError(err => this.restExtractor.handleError(err))) 240 .pipe(catchError(err => this.restExtractor.handleError(err)))
210 } 241 }
211 242
212 getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<User>> { 243 getAnonymousUser () {
244 let videoLanguages
245
246 try {
247 videoLanguages = JSON.parse(this.localStorageService.getItem(User.KEYS.VIDEO_LANGUAGES))
248 } catch (err) {
249 videoLanguages = null
250 console.error('Cannot parse desired video languages from localStorage.', err)
251 }
252
253 return new User({
254 // local storage keys
255 nsfwPolicy: this.localStorageService.getItem(User.KEYS.NSFW_POLICY) as NSFWPolicyType,
256 webTorrentEnabled: this.localStorageService.getItem(User.KEYS.WEBTORRENT_ENABLED) !== 'false',
257 theme: this.localStorageService.getItem(User.KEYS.THEME) || 'default',
258 videoLanguages,
259
260 autoPlayNextVideoPlaylist: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST) !== 'false',
261 autoPlayVideo: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO) === 'true',
262
263 // session storage keys
264 autoPlayNextVideo: this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true'
265 })
266 }
267
268 getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<UserServerModel>> {
213 let params = new HttpParams() 269 let params = new HttpParams()
214 params = this.restService.addRestGetParams(params, pagination, sort) 270 params = this.restService.addRestGetParams(params, pagination, sort)
215 271
216 if (search) params = params.append('search', search) 272 if (search) params = params.append('search', search)
217 273
218 return this.authHttp.get<ResultList<User>>(UserService.BASE_USERS_URL, { params }) 274 return this.authHttp.get<ResultList<UserServerModel>>(UserService.BASE_USERS_URL, { params })
219 .pipe( 275 .pipe(
220 map(res => this.restExtractor.convertResultListDateToHuman(res)), 276 map(res => this.restExtractor.convertResultListDateToHuman(res)),
221 map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))), 277 map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))),
@@ -223,7 +279,7 @@ export class UserService {
223 ) 279 )
224 } 280 }
225 281
226 removeUser (usersArg: User | User[]) { 282 removeUser (usersArg: UserServerModel | UserServerModel[]) {
227 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] 283 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
228 284
229 return from(users) 285 return from(users)
@@ -234,7 +290,7 @@ export class UserService {
234 ) 290 )
235 } 291 }
236 292
237 banUsers (usersArg: User | User[], reason?: string) { 293 banUsers (usersArg: UserServerModel | UserServerModel[], reason?: string) {
238 const body = reason ? { reason } : {} 294 const body = reason ? { reason } : {}
239 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] 295 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
240 296
@@ -246,7 +302,7 @@ export class UserService {
246 ) 302 )
247 } 303 }
248 304
249 unbanUsers (usersArg: User | User[]) { 305 unbanUsers (usersArg: UserServerModel | UserServerModel[]) {
250 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] 306 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
251 307
252 return from(users) 308 return from(users)
@@ -257,7 +313,7 @@ export class UserService {
257 ) 313 )
258 } 314 }
259 315
260 private formatUser (user: User) { 316 private formatUser (user: UserServerModel) {
261 let videoQuota 317 let videoQuota
262 if (user.videoQuota === -1) { 318 if (user.videoQuota === -1) {
263 videoQuota = this.i18n('Unlimited') 319 videoQuota = this.i18n('Unlimited')
diff --git a/client/src/app/shared/video-abuse/video-abuse.service.ts b/client/src/app/shared/video-abuse/video-abuse.service.ts
index b0b59ea0c..700a30239 100644
--- a/client/src/app/shared/video-abuse/video-abuse.service.ts
+++ b/client/src/app/shared/video-abuse/video-abuse.service.ts
@@ -1,9 +1,9 @@
1import { catchError, map } from 'rxjs/operators' 1import { catchError, map } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http' 2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { SortMeta } from 'primeng/components/common/sortmeta' 4import { SortMeta } from 'primeng/api'
5import { Observable } from 'rxjs' 5import { Observable } from 'rxjs'
6import { ResultList, VideoAbuse, VideoAbuseUpdate } from '../../../../../shared' 6import { ResultList, VideoAbuse, VideoAbuseUpdate, VideoAbuseState } from '../../../../../shared'
7import { environment } from '../../../environments/environment' 7import { environment } from '../../../environments/environment'
8import { RestExtractor, RestPagination, RestService } from '../rest' 8import { RestExtractor, RestPagination, RestService } from '../rest'
9 9
@@ -17,15 +17,48 @@ export class VideoAbuseService {
17 private restExtractor: RestExtractor 17 private restExtractor: RestExtractor
18 ) {} 18 ) {}
19 19
20 getVideoAbuses (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoAbuse>> { 20 getVideoAbuses (options: {
21 pagination: RestPagination,
22 sort: SortMeta,
23 search?: string
24 }): Observable<ResultList<VideoAbuse>> {
25 const { pagination, sort, search } = options
21 const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + 'abuse' 26 const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + 'abuse'
22 27
23 let params = new HttpParams() 28 let params = new HttpParams()
24 params = this.restService.addRestGetParams(params, pagination, sort) 29 params = this.restService.addRestGetParams(params, pagination, sort)
25 30
31 if (search) {
32 const filters = this.restService.parseQueryStringFilter(search, {
33 id: { prefix: '#' },
34 state: {
35 prefix: 'state:',
36 handler: v => {
37 if (v === 'accepted') return VideoAbuseState.ACCEPTED
38 if (v === 'pending') return VideoAbuseState.PENDING
39 if (v === 'rejected') return VideoAbuseState.REJECTED
40
41 return undefined
42 }
43 },
44 videoIs: {
45 prefix: 'videoIs:',
46 handler: v => {
47 if (v === 'deleted') return v
48 if (v === 'blacklisted') return v
49
50 return undefined
51 }
52 },
53 searchReporter: { prefix: 'reporter:' },
54 searchReportee: { prefix: 'reportee:' }
55 })
56
57 params = this.restService.addObjectParams(params, filters)
58 }
59
26 return this.authHttp.get<ResultList<VideoAbuse>>(url, { params }) 60 return this.authHttp.get<ResultList<VideoAbuse>>(url, { params })
27 .pipe( 61 .pipe(
28 map(res => this.restExtractor.convertResultListDateToHuman(res)),
29 catchError(res => this.restExtractor.handleError(res)) 62 catchError(res => this.restExtractor.handleError(res))
30 ) 63 )
31 } 64 }
diff --git a/client/src/app/shared/video-blacklist/video-blacklist.service.ts b/client/src/app/shared/video-blacklist/video-blacklist.service.ts
index 491fa698b..c0e13a651 100644
--- a/client/src/app/shared/video-blacklist/video-blacklist.service.ts
+++ b/client/src/app/shared/video-blacklist/video-blacklist.service.ts
@@ -1,7 +1,7 @@
1import { catchError, map, concatMap, toArray } from 'rxjs/operators' 1import { catchError, map, concatMap, toArray } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http' 2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { SortMeta } from 'primeng/components/common/sortmeta' 4import { SortMeta } from 'primeng/api'
5import { from as observableFrom, Observable } from 'rxjs' 5import { from as observableFrom, Observable } from 'rxjs'
6import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared' 6import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared'
7import { Video } from '../video/video.model' 7import { Video } from '../video/video.model'
@@ -19,13 +19,19 @@ export class VideoBlacklistService {
19 private restExtractor: RestExtractor 19 private restExtractor: RestExtractor
20 ) {} 20 ) {}
21 21
22 listBlacklist (pagination: RestPagination, sort: SortMeta, type?: VideoBlacklistType): Observable<ResultList<VideoBlacklist>> { 22 listBlacklist (options: {
23 pagination: RestPagination,
24 sort: SortMeta,
25 search?: string
26 type?: VideoBlacklistType
27 }): Observable<ResultList<VideoBlacklist>> {
28 const { pagination, sort, search, type } = options
29
23 let params = new HttpParams() 30 let params = new HttpParams()
24 params = this.restService.addRestGetParams(params, pagination, sort) 31 params = this.restService.addRestGetParams(params, pagination, sort)
25 32
26 if (type) { 33 if (search) params = params.append('search', search)
27 params = params.set('type', type.toString()) 34 if (type) params = params.append('type', type.toString())
28 }
29 35
30 return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlacklistService.BASE_VIDEOS_URL + 'blacklist', { params }) 36 return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlacklistService.BASE_VIDEOS_URL + 'blacklist', { params })
31 .pipe( 37 .pipe(
diff --git a/client/src/app/shared/video-channel/video-channel.model.ts b/client/src/app/shared/video-channel/video-channel.model.ts
index 309b614ae..617d6d44d 100644
--- a/client/src/app/shared/video-channel/video-channel.model.ts
+++ b/client/src/app/shared/video-channel/video-channel.model.ts
@@ -1,4 +1,4 @@
1import { VideoChannel as ServerVideoChannel } from '../../../../../shared/models/videos' 1import { VideoChannel as ServerVideoChannel, ViewsPerDate } from '../../../../../shared/models/videos'
2import { Actor } from '../actor/actor.model' 2import { Actor } from '../actor/actor.model'
3import { Account } from '../../../../../shared/models/actors' 3import { Account } from '../../../../../shared/models/actors'
4 4
@@ -8,9 +8,11 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
8 support: string 8 support: string
9 isLocal: boolean 9 isLocal: boolean
10 nameWithHost: string 10 nameWithHost: string
11 nameWithHostForced: string
11 ownerAccount?: Account 12 ownerAccount?: Account
12 ownerBy?: string 13 ownerBy?: string
13 ownerAvatarUrl?: string 14 ownerAvatarUrl?: string
15 viewsPerDay?: ViewsPerDate[]
14 16
15 constructor (hash: ServerVideoChannel) { 17 constructor (hash: ServerVideoChannel) {
16 super(hash) 18 super(hash)
@@ -20,6 +22,11 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
20 this.support = hash.support 22 this.support = hash.support
21 this.isLocal = hash.isLocal 23 this.isLocal = hash.isLocal
22 this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) 24 this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
25 this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true)
26
27 if (hash.viewsPerDay) {
28 this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) }))
29 }
23 30
24 if (hash.ownerAccount) { 31 if (hash.ownerAccount) {
25 this.ownerAccount = hash.ownerAccount 32 this.ownerAccount = hash.ownerAccount
diff --git a/client/src/app/shared/video-channel/video-channel.service.ts b/client/src/app/shared/video-channel/video-channel.service.ts
index adb4f4819..0e036bda7 100644
--- a/client/src/app/shared/video-channel/video-channel.service.ts
+++ b/client/src/app/shared/video-channel/video-channel.service.ts
@@ -44,13 +44,18 @@ export class VideoChannelService {
44 ) 44 )
45 } 45 }
46 46
47 listAccountVideoChannels (account: Account, componentPagination?: ComponentPaginationLight): Observable<ResultList<VideoChannel>> { 47 listAccountVideoChannels (
48 account: Account,
49 componentPagination?: ComponentPaginationLight,
50 withStats = false
51 ): Observable<ResultList<VideoChannel>> {
48 const pagination = componentPagination 52 const pagination = componentPagination
49 ? this.restService.componentPaginationToRestPagination(componentPagination) 53 ? this.restService.componentPaginationToRestPagination(componentPagination)
50 : { start: 0, count: 20 } 54 : { start: 0, count: 20 }
51 55
52 let params = new HttpParams() 56 let params = new HttpParams()
53 params = this.restService.addRestGetParams(params, pagination) 57 params = this.restService.addRestGetParams(params, pagination)
58 params = params.set('withStats', withStats + '')
54 59
55 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels' 60 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels'
56 return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params }) 61 return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params })
diff --git a/client/src/app/shared/video-import/video-import.service.ts b/client/src/app/shared/video-import/video-import.service.ts
index 3e3fb7dfb..afd9e3fb5 100644
--- a/client/src/app/shared/video-import/video-import.service.ts
+++ b/client/src/app/shared/video-import/video-import.service.ts
@@ -9,7 +9,7 @@ import { VideoImportCreate, VideoUpdate } from '../../../../../shared/models/vid
9import { objectToFormData } from '@app/shared/misc/utils' 9import { objectToFormData } from '@app/shared/misc/utils'
10import { ResultList } from '../../../../../shared/models/result-list.model' 10import { ResultList } from '../../../../../shared/models/result-list.model'
11import { UserService } from '@app/shared/users/user.service' 11import { UserService } from '@app/shared/users/user.service'
12import { SortMeta } from 'primeng/components/common/sortmeta' 12import { SortMeta } from 'primeng/api'
13import { RestPagination } from '@app/shared/rest' 13import { RestPagination } from '@app/shared/rest'
14import { ServerService } from '@app/core' 14import { ServerService } from '@app/core'
15 15
diff --git a/client/src/app/shared/video-ownership/video-ownership.service.ts b/client/src/app/shared/video-ownership/video-ownership.service.ts
index aa9e4839a..b95d5b792 100644
--- a/client/src/app/shared/video-ownership/video-ownership.service.ts
+++ b/client/src/app/shared/video-ownership/video-ownership.service.ts
@@ -5,7 +5,7 @@ import { environment } from '../../../environments/environment'
5import { RestExtractor, RestService } from '../rest' 5import { RestExtractor, RestService } from '../rest'
6import { VideoChangeOwnershipCreate } from '../../../../../shared/models/videos' 6import { VideoChangeOwnershipCreate } from '../../../../../shared/models/videos'
7import { Observable } from 'rxjs/index' 7import { Observable } from 'rxjs/index'
8import { SortMeta } from 'primeng/components/common/sortmeta' 8import { SortMeta } from 'primeng/api'
9import { ResultList, VideoChangeOwnership } from '../../../../../shared' 9import { ResultList, VideoChangeOwnership } from '../../../../../shared'
10import { RestPagination } from '@app/shared/rest' 10import { RestPagination } from '@app/shared/rest'
11import { VideoChangeOwnershipAccept } from '../../../../../shared/models/videos/video-change-ownership-accept.model' 11import { VideoChangeOwnershipAccept } from '../../../../../shared/models/videos/video-change-ownership-accept.model'
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.html b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html
index 6e0989227..58108584b 100644
--- a/client/src/app/shared/video-playlist/video-add-to-playlist.component.html
+++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html
@@ -62,7 +62,7 @@
62 <div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened"> 62 <div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened">
63 <my-global-icon iconName="add"></my-global-icon> 63 <my-global-icon iconName="add"></my-global-icon>
64 64
65 Create a private playlist 65 <span i18n>Create a private playlist</span>
66 </div> 66 </div>
67 67
68 <form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form"> 68 <form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form">
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss
index f1b6cd601..1724449e8 100644
--- a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss
+++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss
@@ -4,7 +4,7 @@
4.header, 4.header,
5.dropdown-item, 5.dropdown-item,
6.input-container { 6.input-container {
7 padding: 6px 24px 10px 24px; 7 padding: 8px 24px;
8} 8}
9 9
10.header { 10.header {
@@ -54,11 +54,12 @@
54} 54}
55 55
56.playlist { 56.playlist {
57 display: flex; 57 display: inline-flex;
58 cursor: pointer; 58 cursor: pointer;
59 59
60 my-peertube-checkbox { 60 my-peertube-checkbox {
61 margin-right: 10px; 61 margin-right: 10px;
62 align-self: center;
62 } 63 }
63 64
64 .display-name { 65 .display-name {
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
index 4864581b5..a2c0724cd 100644
--- a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
+++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
@@ -18,7 +18,7 @@ import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-
18 changeDetection: ChangeDetectionStrategy.OnPush 18 changeDetection: ChangeDetectionStrategy.OnPush
19}) 19})
20export class VideoPlaylistElementMiniatureComponent implements OnInit { 20export class VideoPlaylistElementMiniatureComponent implements OnInit {
21 @ViewChild('moreDropdown', { static: false }) moreDropdown: NgbDropdown 21 @ViewChild('moreDropdown') moreDropdown: NgbDropdown
22 22
23 @Input() playlist: VideoPlaylist 23 @Input() playlist: VideoPlaylist
24 @Input() playlistElement: VideoPlaylistElement 24 @Input() playlistElement: VideoPlaylistElement
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss
index 3c7a4b1fc..44b629542 100644
--- a/client/src/app/shared/video/abstract-video-list.scss
+++ b/client/src/app/shared/video/abstract-video-list.scss
@@ -15,6 +15,11 @@
15 top: 1px; 15 top: 1px;
16 margin-left: 5px; 16 margin-left: 5px;
17 width: max-content; 17 width: max-content;
18 opacity: 0;
19 transition: ease-in .2s opacity;
20 }
21 &:hover my-feed {
22 opacity: 1;
18 } 23 }
19 } 24 }
20 25
@@ -50,3 +55,15 @@
50 @include adapt-margin-content-width; 55 @include adapt-margin-content-width;
51} 56}
52 57
58@media screen and (max-width: $mobile-view) {
59 .videos-header {
60 flex-direction: column;
61 align-items: center;
62 height: auto;
63
64 .title-page {
65 margin-bottom: 10px;
66 margin-right: 0px;
67 }
68 }
69}
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts
index c2fe6f754..b146d7014 100644
--- a/client/src/app/shared/video/abstract-video-list.ts
+++ b/client/src/app/shared/video/abstract-video-list.ts
@@ -1,4 +1,4 @@
1import { debounceTime, first, tap } from 'rxjs/operators' 1import { debounceTime, first, tap, throttleTime } from 'rxjs/operators'
2import { OnDestroy, OnInit } from '@angular/core' 2import { OnDestroy, OnInit } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs' 4import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs'
@@ -14,6 +14,9 @@ import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
14import { I18n } from '@ngx-translate/i18n-polyfill' 14import { I18n } from '@ngx-translate/i18n-polyfill'
15import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date' 15import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date'
16import { ServerConfig } from '@shared/models' 16import { ServerConfig } from '@shared/models'
17import { GlobalIconName } from '@app/shared/images/global-icon.component'
18import { UserService, User } from '../users'
19import { LocalStorageService } from '../misc/storage.service'
17 20
18enum GroupDate { 21enum GroupDate {
19 UNKNOWN = 0, 22 UNKNOWN = 0,
@@ -61,7 +64,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
61 64
62 actions: { 65 actions: {
63 routerLink: string 66 routerLink: string
64 iconName: string 67 iconName: GlobalIconName
65 label: string 68 label: string
66 }[] = [] 69 }[] = []
67 70
@@ -71,9 +74,11 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
71 74
72 protected abstract notifier: Notifier 75 protected abstract notifier: Notifier
73 protected abstract authService: AuthService 76 protected abstract authService: AuthService
77 protected abstract userService: UserService
74 protected abstract route: ActivatedRoute 78 protected abstract route: ActivatedRoute
75 protected abstract serverService: ServerService 79 protected abstract serverService: ServerService
76 protected abstract screenService: ScreenService 80 protected abstract screenService: ScreenService
81 protected abstract storageService: LocalStorageService
77 protected abstract router: Router 82 protected abstract router: Router
78 protected abstract i18n: I18n 83 protected abstract i18n: I18n
79 abstract titlePage: string 84 abstract titlePage: string
@@ -123,6 +128,16 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
123 if (this.loadOnInit === true) { 128 if (this.loadOnInit === true) {
124 loadUserObservable.subscribe(() => this.loadMoreVideos()) 129 loadUserObservable.subscribe(() => this.loadMoreVideos())
125 } 130 }
131
132 this.storageService.watch([
133 User.KEYS.NSFW_POLICY,
134 User.KEYS.VIDEO_LANGUAGES
135 ]).pipe(throttleTime(200)).subscribe(
136 () => {
137 this.loadUserVideoLanguagesIfNeeded()
138 if (this.hasDoneFirstQuery) this.reloadVideos()
139 }
140 )
126 } 141 }
127 142
128 ngOnDestroy () { 143 ngOnDestroy () {
@@ -278,7 +293,12 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
278 } 293 }
279 294
280 private loadUserVideoLanguagesIfNeeded () { 295 private loadUserVideoLanguagesIfNeeded () {
281 if (!this.authService.isLoggedIn() || !this.useUserVideoLanguagePreferences) { 296 if (!this.useUserVideoLanguagePreferences) {
297 return of(true)
298 }
299
300 if (!this.authService.isLoggedIn()) {
301 this.languageOneOf = this.userService.getAnonymousUser().videoLanguages
282 return of(true) 302 return of(true)
283 } 303 }
284 304
diff --git a/client/src/app/shared/video/infinite-scroller.directive.ts b/client/src/app/shared/video/infinite-scroller.directive.ts
index 9f613c5fa..f09c3d1fc 100644
--- a/client/src/app/shared/video/infinite-scroller.directive.ts
+++ b/client/src/app/shared/video/infinite-scroller.directive.ts
@@ -1,4 +1,4 @@
1import { distinctUntilChanged, filter, map, share, startWith, tap, throttleTime } from 'rxjs/operators' 1import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
2import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' 2import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
3import { fromEvent, Observable, Subscription } from 'rxjs' 3import { fromEvent, Observable, Subscription } from 'rxjs'
4 4
@@ -53,7 +53,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterConten
53 const scrollableElement = this.onItself ? this.container : window 53 const scrollableElement = this.onItself ? this.container : window
54 const scrollObservable = fromEvent(scrollableElement, 'scroll') 54 const scrollObservable = fromEvent(scrollableElement, 'scroll')
55 .pipe( 55 .pipe(
56 startWith(null as string), // FIXME: typings 56 startWith(true),
57 throttleTime(200, undefined, throttleOptions), 57 throttleTime(200, undefined, throttleOptions),
58 map(() => this.getScrollInfo()), 58 map(() => this.getScrollInfo()),
59 distinctUntilChanged((o1, o2) => o1.current === o2.current), 59 distinctUntilChanged((o1, o2) => o1.current === o2.current),
diff --git a/client/src/app/shared/video/modals/video-blacklist.component.html b/client/src/app/shared/video/modals/video-blacklist.component.html
index 1a87bdcd4..8f06a6b02 100644
--- a/client/src/app/shared/video/modals/video-blacklist.component.html
+++ b/client/src/app/shared/video/modals/video-blacklist.component.html
@@ -8,8 +8,10 @@
8 8
9 <form novalidate [formGroup]="form" (ngSubmit)="blacklist()"> 9 <form novalidate [formGroup]="form" (ngSubmit)="blacklist()">
10 <div class="form-group"> 10 <div class="form-group">
11 <textarea i18n-placeholder placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }"> 11 <textarea
12 </textarea> 12 i18n-placeholder placeholder="Reason..." formControlName="reason"
13 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
14 ></textarea>
13 <div *ngIf="formErrors.reason" class="form-error"> 15 <div *ngIf="formErrors.reason" class="form-error">
14 {{ formErrors.reason }} 16 {{ formErrors.reason }}
15 </div> 17 </div>
@@ -18,14 +20,19 @@
18 <div class="form-group" *ngIf="video.isLocal"> 20 <div class="form-group" *ngIf="video.isLocal">
19 <my-peertube-checkbox 21 <my-peertube-checkbox
20 inputName="unfederate" formControlName="unfederate" 22 inputName="unfederate" formControlName="unfederate"
21 i18n-labelText labelText="Unfederate the video (ask for its deletion from the remote instances)" 23 i18n-labelText labelText="Unfederate the video"
22 ></my-peertube-checkbox> 24 >
25 <ng-container ngProjectAs="description">
26 <span i18n>This will ask remote instances to delete it</span>
27 </ng-container>
28 </my-peertube-checkbox>
23 </div> 29 </div>
24 30
25 <div class="form-group inputs"> 31 <div class="form-group inputs">
26 <span i18n class="action-button action-button-cancel" (click)="hide()"> 32 <input
27 Cancel 33 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
28 </span> 34 (click)="hide()" (key.enter)="hide()"
35 >
29 36
30 <input 37 <input
31 type="submit" i18n-value value="Submit" class="action-button-submit" 38 type="submit" i18n-value value="Submit" class="action-button-submit"
diff --git a/client/src/app/shared/video/modals/video-blacklist.component.ts b/client/src/app/shared/video/modals/video-blacklist.component.ts
index f0c70a365..6ef9c250b 100644
--- a/client/src/app/shared/video/modals/video-blacklist.component.ts
+++ b/client/src/app/shared/video/modals/video-blacklist.component.ts
@@ -1,12 +1,12 @@
1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier, RedirectService } from '@app/core' 2import { Notifier, RedirectService } from '@app/core'
3import { VideoBlacklistService } from '../../../shared/video-blacklist' 3import { VideoBlacklistService } from '../../../shared/video-blacklist'
4import { VideoDetails } from '../../../shared/video/video-details.model'
5import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 5import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
9import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms' 8import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms'
9import { Video } from '@app/shared/video/video.model'
10 10
11@Component({ 11@Component({
12 selector: 'my-video-blacklist', 12 selector: 'my-video-blacklist',
@@ -14,7 +14,7 @@ import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms
14 styleUrls: [ './video-blacklist.component.scss' ] 14 styleUrls: [ './video-blacklist.component.scss' ]
15}) 15})
16export class VideoBlacklistComponent extends FormReactive implements OnInit { 16export class VideoBlacklistComponent extends FormReactive implements OnInit {
17 @Input() video: VideoDetails = null 17 @Input() video: Video = null
18 18
19 @ViewChild('modal', { static: true }) modal: NgbModal 19 @ViewChild('modal', { static: true }) modal: NgbModal
20 20
@@ -46,7 +46,7 @@ export class VideoBlacklistComponent extends FormReactive implements OnInit {
46 } 46 }
47 47
48 show () { 48 show () {
49 this.openedModal = this.modalService.open(this.modal, { keyboard: false }) 49 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
50 } 50 }
51 51
52 hide () { 52 hide () {
diff --git a/client/src/app/shared/video/modals/video-download.component.html b/client/src/app/shared/video/modals/video-download.component.html
index 8cca985b1..c65e371ee 100644
--- a/client/src/app/shared/video/modals/video-download.component.html
+++ b/client/src/app/shared/video/modals/video-download.component.html
@@ -1,7 +1,7 @@
1<ng-template #modal let-hide="close"> 1<ng-template #modal let-hide="close">
2 <div class="modal-header"> 2 <div class="modal-header">
3 <h4 class="modal-title">Download 3 <h4 class="modal-title">
4 <span *ngIf="!videoCaptions" i18n>video</span> 4 <ng-container i18n>Download</ng-container>
5 5
6 <div *ngIf="videoCaptions" ngbDropdown class="d-inline-block"> 6 <div *ngIf="videoCaptions" ngbDropdown class="d-inline-block">
7 <span id="dropdownDownloadType" ngbDropdownToggle> 7 <span id="dropdownDownloadType" ngbDropdownToggle>
@@ -20,22 +20,67 @@
20 <div class="form-group"> 20 <div class="form-group">
21 <div class="input-group input-group-sm"> 21 <div class="input-group input-group-sm">
22 <div class="input-group-prepend peertube-select-container"> 22 <div class="input-group-prepend peertube-select-container">
23 <select *ngIf="type === 'video'" [(ngModel)]="resolutionId"> 23 <select *ngIf="type === 'video'" [(ngModel)]="resolutionId" (ngModelChange)="onResolutionIdChange()">
24 <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option> 24 <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option>
25 </select> 25 </select>
26
26 <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId"> 27 <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId">
27 <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option> 28 <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option>
28 </select> 29 </select>
29 </div> 30 </div>
31
30 <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" /> 32 <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" />
31 <div class="input-group-append"> 33 <div class="input-group-append">
32 <button [ngxClipboard]="urlInput" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary"> 34 <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
33 <span class="glyphicon glyphicon-copy"></span> 35 <span class="glyphicon glyphicon-copy"></span>
34 </button> 36 </button>
35 </div> 37 </div>
36 </div> 38 </div>
37 </div> 39 </div>
38 40
41 <ng-container *ngIf="type === 'video' && videoFile?.metadata">
42 <div ngbNav #nav="ngbNav" class="nav-tabs">
43
44 <ng-container ngbNavItem>
45 <a ngbNavLink i18n>Format</a>
46 <ng-template ngbNavContent>
47 <div class="file-metadata">
48 <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataFormat | keyvalue">
49 <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
50 <span class="metadata-attribute-value">{{ item.value.value }}</span>
51 </div>
52 </div>
53 </ng-template>
54 </ng-container>
55
56 <ng-container ngbNavItem [disabled]="videoFileMetadataVideoStream === undefined">
57 <a ngbNavLink i18n>Video stream</a>
58 <ng-template ngbNavContent>
59 <div class="file-metadata">
60 <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataVideoStream | keyvalue">
61 <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
62 <span class="metadata-attribute-value">{{ item.value.value }}</span>
63 </div>
64 </div>
65 </ng-template>
66 </ng-container>
67
68 <ng-container ngbNavItem [disabled]="videoFileMetadataAudioStream === undefined">
69 <a ngbNavLink i18n>Audio stream</a>
70 <ng-template ngbNavContent>
71 <div class="file-metadata">
72 <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataAudioStream | keyvalue">
73 <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
74 <span class="metadata-attribute-value">{{ item.value.value }}</span>
75 </div>
76 </div>
77 </ng-template>
78 </ng-container>
79 </div>
80
81 <div [ngbNavOutlet]="nav"></div>
82 </ng-container>
83
39 <div class="download-type" *ngIf="type === 'video'"> 84 <div class="download-type" *ngIf="type === 'video'">
40 <div class="peertube-radio-container"> 85 <div class="peertube-radio-container">
41 <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct"> 86 <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
@@ -50,9 +95,10 @@
50 </div> 95 </div>
51 96
52 <div class="modal-footer inputs"> 97 <div class="modal-footer inputs">
53 <span i18n class="action-button action-button-cancel" (click)="hide()"> 98 <input
54 Cancel 99 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
55 </span> 100 (click)="hide()" (key.enter)="hide()"
101 >
56 102
57 <input 103 <input
58 type="submit" i18n-value value="Download" class="action-button-submit" 104 type="submit" i18n-value value="Download" class="action-button-submit"
diff --git a/client/src/app/shared/video/modals/video-download.component.scss b/client/src/app/shared/video/modals/video-download.component.scss
index 09dd91aa9..f28bc34ed 100644
--- a/client/src/app/shared/video/modals/video-download.component.scss
+++ b/client/src/app/shared/video/modals/video-download.component.scss
@@ -27,3 +27,38 @@
27 margin-right: 30px; 27 margin-right: 30px;
28 } 28 }
29} 29}
30
31.file-metadata {
32 padding: 1rem;
33}
34
35.file-metadata .metadata-attribute {
36 font-size: 13px;
37 display: block;
38 margin-bottom: 12px;
39
40 .metadata-attribute-label {
41 min-width: 142px;
42 padding-right: 5px;
43 display: inline-block;
44 color: $grey-foreground-color;
45 font-weight: $font-bold;
46 }
47
48 a.metadata-attribute-value {
49 @include disable-default-a-behaviour;
50 color: var(--mainForegroundColor);
51
52 &:hover {
53 opacity: 0.9;
54 }
55 }
56
57 &.metadata-attribute-tags {
58 .metadata-attribute-value:not(:nth-child(2)) {
59 &::before {
60 content: ', '
61 }
62 }
63 }
64}
diff --git a/client/src/app/shared/video/modals/video-download.component.ts b/client/src/app/shared/video/modals/video-download.component.ts
index c1ceca263..d77187821 100644
--- a/client/src/app/shared/video/modals/video-download.component.ts
+++ b/client/src/app/shared/video/modals/video-download.component.ts
@@ -3,9 +3,15 @@ import { VideoDetails } from '../../../shared/video/video-details.model'
3import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' 3import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { AuthService, Notifier } from '@app/core' 5import { AuthService, Notifier } from '@app/core'
6import { VideoPrivacy, VideoCaption } from '@shared/models' 6import { VideoPrivacy, VideoCaption, VideoFile } from '@shared/models'
7import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg'
8import { mapValues, pick } from 'lodash-es'
9import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
10import { BytesPipe } from 'ngx-pipes'
11import { VideoService } from '../video.service'
7 12
8type DownloadType = 'video' | 'subtitles' 13type DownloadType = 'video' | 'subtitles'
14type FileMetadata = { [key: string]: { label: string, value: string }}
9 15
10@Component({ 16@Component({
11 selector: 'my-video-download', 17 selector: 'my-video-download',
@@ -20,17 +26,28 @@ export class VideoDownloadComponent {
20 subtitleLanguageId: string 26 subtitleLanguageId: string
21 27
22 video: VideoDetails 28 video: VideoDetails
29 videoFile: VideoFile
30 videoFileMetadataFormat: FileMetadata
31 videoFileMetadataVideoStream: FileMetadata | undefined
32 videoFileMetadataAudioStream: FileMetadata | undefined
23 videoCaptions: VideoCaption[] 33 videoCaptions: VideoCaption[]
24 activeModal: NgbActiveModal 34 activeModal: NgbActiveModal
25 35
26 type: DownloadType = 'video' 36 type: DownloadType = 'video'
27 37
38 private bytesPipe: BytesPipe
39 private numbersPipe: NumberFormatterPipe
40
28 constructor ( 41 constructor (
29 private notifier: Notifier, 42 private notifier: Notifier,
30 private modalService: NgbModal, 43 private modalService: NgbModal,
44 private videoService: VideoService,
31 private auth: AuthService, 45 private auth: AuthService,
32 private i18n: I18n 46 private i18n: I18n
33 ) { } 47 ) {
48 this.bytesPipe = new BytesPipe()
49 this.numbersPipe = new NumberFormatterPipe()
50 }
34 51
35 get typeText () { 52 get typeText () {
36 return this.type === 'video' 53 return this.type === 'video'
@@ -48,9 +65,10 @@ export class VideoDownloadComponent {
48 this.video = video 65 this.video = video
49 this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined 66 this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined
50 67
51 this.activeModal = this.modalService.open(this.modal) 68 this.activeModal = this.modalService.open(this.modal, { centered: true })
52 69
53 this.resolutionId = this.getVideoFiles()[0].resolution.id 70 this.resolutionId = this.getVideoFiles()[0].resolution.id
71 this.onResolutionIdChange()
54 if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id 72 if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
55 } 73 }
56 74
@@ -67,10 +85,27 @@ export class VideoDownloadComponent {
67 getLink () { 85 getLink () {
68 return this.type === 'subtitles' && this.videoCaptions 86 return this.type === 'subtitles' && this.videoCaptions
69 ? this.getSubtitlesLink() 87 ? this.getSubtitlesLink()
70 : this.getVideoLink() 88 : this.getVideoFileLink()
71 } 89 }
72 90
73 getVideoLink () { 91 async onResolutionIdChange () {
92 this.videoFile = this.getVideoFile()
93 if (this.videoFile.metadata || !this.videoFile.metadataUrl) return
94
95 await this.hydrateMetadataFromMetadataUrl(this.videoFile)
96
97 this.videoFileMetadataFormat = this.videoFile
98 ? this.getMetadataFormat(this.videoFile.metadata.format)
99 : undefined
100 this.videoFileMetadataVideoStream = this.videoFile
101 ? this.getMetadataStream(this.videoFile.metadata.streams, 'video')
102 : undefined
103 this.videoFileMetadataAudioStream = this.videoFile
104 ? this.getMetadataStream(this.videoFile.metadata.streams, 'audio')
105 : undefined
106 }
107
108 getVideoFile () {
74 // HTML select send us a string, so convert it to a number 109 // HTML select send us a string, so convert it to a number
75 this.resolutionId = parseInt(this.resolutionId.toString(), 10) 110 this.resolutionId = parseInt(this.resolutionId.toString(), 10)
76 111
@@ -79,6 +114,12 @@ export class VideoDownloadComponent {
79 console.error('Could not find file with resolution %d.', this.resolutionId) 114 console.error('Could not find file with resolution %d.', this.resolutionId)
80 return 115 return
81 } 116 }
117 return file
118 }
119
120 getVideoFileLink () {
121 const file = this.videoFile
122 if (!file) return
82 123
83 const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL 124 const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL
84 ? '?access_token=' + this.auth.getAccessToken() 125 ? '?access_token=' + this.auth.getAccessToken()
@@ -104,4 +145,64 @@ export class VideoDownloadComponent {
104 switchToType (type: DownloadType) { 145 switchToType (type: DownloadType) {
105 this.type = type 146 this.type = type
106 } 147 }
148
149 getMetadataFormat (format: FfprobeFormat) {
150 const keyToTranslateFunction = {
151 'encoder': (value: string) => ({ label: this.i18n('Encoder'), value }),
152 'format_long_name': (value: string) => ({ label: this.i18n('Format name'), value }),
153 'size': (value: number) => ({ label: this.i18n('Size'), value: this.bytesPipe.transform(value, 2) }),
154 'bit_rate': (value: number) => ({
155 label: this.i18n('Bitrate'),
156 value: `${this.numbersPipe.transform(value)}bps`
157 })
158 }
159
160 // flattening format
161 const sanitizedFormat = Object.assign(format, format.tags)
162 delete sanitizedFormat.tags
163
164 return mapValues(
165 pick(sanitizedFormat, Object.keys(keyToTranslateFunction)),
166 (val, key) => keyToTranslateFunction[key](val)
167 )
168 }
169
170 getMetadataStream (streams: FfprobeStream[], type: 'video' | 'audio') {
171 const stream = streams.find(s => s.codec_type === type)
172 if (!stream) return undefined
173
174 let keyToTranslateFunction = {
175 'codec_long_name': (value: string) => ({ label: this.i18n('Codec'), value }),
176 'profile': (value: string) => ({ label: this.i18n('Profile'), value }),
177 'bit_rate': (value: number) => ({
178 label: this.i18n('Bitrate'),
179 value: `${this.numbersPipe.transform(value)}bps`
180 })
181 }
182
183 if (type === 'video') {
184 keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
185 'width': (value: number) => ({ label: this.i18n('Resolution'), value: `${value}x${stream.height}` }),
186 'display_aspect_ratio': (value: string) => ({ label: this.i18n('Aspect ratio'), value }),
187 'avg_frame_rate': (value: string) => ({ label: this.i18n('Average frame rate'), value }),
188 'pix_fmt': (value: string) => ({ label: this.i18n('Pixel format'), value })
189 })
190 } else {
191 keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
192 'sample_rate': (value: number) => ({ label: this.i18n('Sample rate'), value }),
193 'channel_layout': (value: number) => ({ label: this.i18n('Channel Layout'), value })
194 })
195 }
196
197 return mapValues(
198 pick(stream, Object.keys(keyToTranslateFunction)),
199 (val, key) => keyToTranslateFunction[key](val)
200 )
201 }
202
203 private hydrateMetadataFromMetadataUrl (file: VideoFile) {
204 const observable = this.videoService.getVideoFileMetadata(file.metadataUrl)
205 observable.subscribe(res => file.metadata = res)
206 return observable.toPromise()
207 }
107} 208}
diff --git a/client/src/app/shared/video/modals/video-report.component.html b/client/src/app/shared/video/modals/video-report.component.html
index b9434da26..e336b6660 100644
--- a/client/src/app/shared/video/modals/video-report.component.html
+++ b/client/src/app/shared/video/modals/video-report.component.html
@@ -7,23 +7,25 @@
7 <div class="modal-body"> 7 <div class="modal-body">
8 8
9 <div i18n class="information"> 9 <div i18n class="information">
10 Your report will be sent to moderators of {{ currentHost }}. 10 Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>.
11 <ng-container *ngIf="isRemoteVideo()"> It will be forwarded to origin instance {{ originHost }} too.</ng-container>
12 </div> 11 </div>
13 12
14 <form novalidate [formGroup]="form" (ngSubmit)="report()"> 13 <form novalidate [formGroup]="form" (ngSubmit)="report()">
15 <div class="form-group"> 14 <div class="form-group">
16 <textarea i18n-placeholder placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }"> 15 <textarea
17 </textarea> 16 i18n-placeholder placeholder="Reason..." formControlName="reason"
17 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
18 ></textarea>
18 <div *ngIf="formErrors.reason" class="form-error"> 19 <div *ngIf="formErrors.reason" class="form-error">
19 {{ formErrors.reason }} 20 {{ formErrors.reason }}
20 </div> 21 </div>
21 </div> 22 </div>
22 23
23 <div class="form-group inputs"> 24 <div class="form-group inputs">
24 <span i18n class="action-button action-button-cancel" (click)="hide()"> 25 <input
25 Cancel 26 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
26 </span> 27 (click)="hide()" (key.enter)="hide()"
28 >
27 29
28 <input 30 <input
29 type="submit" i18n-value value="Submit" class="action-button-submit" 31 type="submit" i18n-value value="Submit" class="action-button-submit"
diff --git a/client/src/app/shared/video/modals/video-report.component.ts b/client/src/app/shared/video/modals/video-report.component.ts
index 1d368ff17..988fa03d4 100644
--- a/client/src/app/shared/video/modals/video-report.component.ts
+++ b/client/src/app/shared/video/modals/video-report.component.ts
@@ -1,13 +1,13 @@
1import { Component, Input, OnInit, ViewChild } from '@angular/core' 1import { Component, Input, OnInit, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { FormReactive } from '../../../shared/forms' 3import { FormReactive } from '../../../shared/forms'
4import { VideoDetails } from '../../../shared/video/video-details.model'
5import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 5import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' 6import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service'
8import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
9import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
10import { VideoAbuseService } from '@app/shared/video-abuse' 9import { VideoAbuseService } from '@app/shared/video-abuse'
10import { Video } from '@app/shared/video/video.model'
11 11
12@Component({ 12@Component({
13 selector: 'my-video-report', 13 selector: 'my-video-report',
@@ -15,7 +15,7 @@ import { VideoAbuseService } from '@app/shared/video-abuse'
15 styleUrls: [ './video-report.component.scss' ] 15 styleUrls: [ './video-report.component.scss' ]
16}) 16})
17export class VideoReportComponent extends FormReactive implements OnInit { 17export class VideoReportComponent extends FormReactive implements OnInit {
18 @Input() video: VideoDetails = null 18 @Input() video: Video = null
19 19
20 @ViewChild('modal', { static: true }) modal: NgbModal 20 @ViewChild('modal', { static: true }) modal: NgbModal
21 21
@@ -53,7 +53,7 @@ export class VideoReportComponent extends FormReactive implements OnInit {
53 } 53 }
54 54
55 show () { 55 show () {
56 this.openedModal = this.modalService.open(this.modal, { keyboard: false }) 56 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
57 } 57 }
58 58
59 hide () { 59 hide () {
diff --git a/client/src/app/shared/video/redundancy.service.ts b/client/src/app/shared/video/redundancy.service.ts
new file mode 100644
index 000000000..fb918d73b
--- /dev/null
+++ b/client/src/app/shared/video/redundancy.service.ts
@@ -0,0 +1,73 @@
1import { catchError, map, toArray } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor, RestPagination, RestService } from '@app/shared/rest'
5import { SortMeta } from 'primeng/api'
6import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
7import { concat, Observable } from 'rxjs'
8import { environment } from '../../../environments/environment'
9
10@Injectable()
11export class RedundancyService {
12 static BASE_REDUNDANCY_URL = environment.apiUrl + '/api/v1/server/redundancy'
13
14 constructor (
15 private authHttp: HttpClient,
16 private restService: RestService,
17 private restExtractor: RestExtractor
18 ) { }
19
20 updateRedundancy (host: string, redundancyAllowed: boolean) {
21 const url = RedundancyService.BASE_REDUNDANCY_URL + '/' + host
22
23 const body = { redundancyAllowed }
24
25 return this.authHttp.put(url, body)
26 .pipe(
27 map(this.restExtractor.extractDataBool),
28 catchError(err => this.restExtractor.handleError(err))
29 )
30 }
31
32 listVideoRedundancies (options: {
33 pagination: RestPagination,
34 sort: SortMeta,
35 target?: VideoRedundanciesTarget
36 }): Observable<ResultList<VideoRedundancy>> {
37 const { pagination, sort, target } = options
38
39 let params = new HttpParams()
40 params = this.restService.addRestGetParams(params, pagination, sort)
41
42 if (target) params = params.append('target', target)
43
44 return this.authHttp.get<ResultList<VideoRedundancy>>(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { params })
45 .pipe(
46 catchError(res => this.restExtractor.handleError(res))
47 )
48 }
49
50 addVideoRedundancy (video: Video) {
51 return this.authHttp.post(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { videoId: video.id })
52 .pipe(
53 catchError(res => this.restExtractor.handleError(res))
54 )
55 }
56
57 removeVideoRedundancies (redundancy: VideoRedundancy) {
58 const observables = redundancy.redundancies.streamingPlaylists.map(r => r.id)
59 .concat(redundancy.redundancies.files.map(r => r.id))
60 .map(id => this.removeRedundancy(id))
61
62 return concat(...observables)
63 .pipe(toArray())
64 }
65
66 private removeRedundancy (redundancyId: number) {
67 return this.authHttp.delete(RedundancyService.BASE_REDUNDANCY_URL + '/videos/' + redundancyId)
68 .pipe(
69 map(this.restExtractor.extractDataBool),
70 catchError(res => this.restExtractor.handleError(res))
71 )
72 }
73}
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.ts b/client/src/app/shared/video/video-actions-dropdown.component.ts
index afdeab18d..4e5fc6476 100644
--- a/client/src/app/shared/video/video-actions-dropdown.component.ts
+++ b/client/src/app/shared/video/video-actions-dropdown.component.ts
@@ -1,8 +1,7 @@
1import { AfterViewInit, Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
2import { I18n } from '@ngx-translate/i18n-polyfill' 2import { I18n } from '@ngx-translate/i18n-polyfill'
3import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component' 3import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component'
4import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' 4import { AuthService, ConfirmService, Notifier } from '@app/core'
5import { BlocklistService } from '@app/shared/blocklist'
6import { Video } from '@app/shared/video/video.model' 5import { Video } from '@app/shared/video/video.model'
7import { VideoService } from '@app/shared/video/video.service' 6import { VideoService } from '@app/shared/video/video.service'
8import { VideoDetails } from '@app/shared/video/video-details.model' 7import { VideoDetails } from '@app/shared/video/video-details.model'
@@ -14,6 +13,7 @@ import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklis
14import { VideoBlacklistService } from '@app/shared/video-blacklist' 13import { VideoBlacklistService } from '@app/shared/video-blacklist'
15import { ScreenService } from '@app/shared/misc/screen.service' 14import { ScreenService } from '@app/shared/misc/screen.service'
16import { VideoCaption } from '@shared/models' 15import { VideoCaption } from '@shared/models'
16import { RedundancyService } from '@app/shared/video/redundancy.service'
17 17
18export type VideoActionsDisplayType = { 18export type VideoActionsDisplayType = {
19 playlist?: boolean 19 playlist?: boolean
@@ -22,6 +22,7 @@ export type VideoActionsDisplayType = {
22 blacklist?: boolean 22 blacklist?: boolean
23 delete?: boolean 23 delete?: boolean
24 report?: boolean 24 report?: boolean
25 duplicate?: boolean
25} 26}
26 27
27@Component({ 28@Component({
@@ -30,12 +31,12 @@ export type VideoActionsDisplayType = {
30 styleUrls: [ './video-actions-dropdown.component.scss' ] 31 styleUrls: [ './video-actions-dropdown.component.scss' ]
31}) 32})
32export class VideoActionsDropdownComponent implements OnChanges { 33export class VideoActionsDropdownComponent implements OnChanges {
33 @ViewChild('playlistDropdown', { static: false }) playlistDropdown: NgbDropdown 34 @ViewChild('playlistDropdown') playlistDropdown: NgbDropdown
34 @ViewChild('playlistAdd', { static: false }) playlistAdd: VideoAddToPlaylistComponent 35 @ViewChild('playlistAdd') playlistAdd: VideoAddToPlaylistComponent
35 36
36 @ViewChild('videoDownloadModal', { static: false }) videoDownloadModal: VideoDownloadComponent 37 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
37 @ViewChild('videoReportModal', { static: false }) videoReportModal: VideoReportComponent 38 @ViewChild('videoReportModal') videoReportModal: VideoReportComponent
38 @ViewChild('videoBlacklistModal', { static: false }) videoBlacklistModal: VideoBlacklistComponent 39 @ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent
39 40
40 @Input() video: Video | VideoDetails 41 @Input() video: Video | VideoDetails
41 @Input() videoCaptions: VideoCaption[] = [] 42 @Input() videoCaptions: VideoCaption[] = []
@@ -46,7 +47,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
46 update: true, 47 update: true,
47 blacklist: true, 48 blacklist: true,
48 delete: true, 49 delete: true,
49 report: true 50 report: true,
51 duplicate: true
50 } 52 }
51 @Input() placement = 'left' 53 @Input() placement = 'left'
52 54
@@ -70,10 +72,9 @@ export class VideoActionsDropdownComponent implements OnChanges {
70 private notifier: Notifier, 72 private notifier: Notifier,
71 private confirmService: ConfirmService, 73 private confirmService: ConfirmService,
72 private videoBlacklistService: VideoBlacklistService, 74 private videoBlacklistService: VideoBlacklistService,
73 private serverService: ServerService,
74 private screenService: ScreenService, 75 private screenService: ScreenService,
75 private videoService: VideoService, 76 private videoService: VideoService,
76 private blocklistService: BlocklistService, 77 private redundancyService: RedundancyService,
77 private i18n: I18n 78 private i18n: I18n
78 ) { } 79 ) { }
79 80
@@ -144,6 +145,10 @@ export class VideoActionsDropdownComponent implements OnChanges {
144 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled 145 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled
145 } 146 }
146 147
148 canVideoBeDuplicated () {
149 return this.video.canBeDuplicatedBy(this.user)
150 }
151
147 /* Action handlers */ 152 /* Action handlers */
148 153
149 async unblacklistVideo () { 154 async unblacklistVideo () {
@@ -186,6 +191,18 @@ export class VideoActionsDropdownComponent implements OnChanges {
186 ) 191 )
187 } 192 }
188 193
194 duplicateVideo () {
195 this.redundancyService.addVideoRedundancy(this.video)
196 .subscribe(
197 () => {
198 const message = this.i18n('This video will be duplicated by your instance.')
199 this.notifier.success(message)
200 },
201
202 err => this.notifier.error(err.message)
203 )
204 }
205
189 onVideoBlacklisted () { 206 onVideoBlacklisted () {
190 this.videoBlacklisted.emit() 207 this.videoBlacklisted.emit()
191 } 208 }
@@ -234,6 +251,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
234 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblacklistable() 251 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblacklistable()
235 }, 252 },
236 { 253 {
254 label: this.i18n('Mirror'),
255 handler: () => this.duplicateVideo(),
256 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.duplicate && this.canVideoBeDuplicated(),
257 iconName: 'cloud-download'
258 },
259 {
237 label: this.i18n('Delete'), 260 label: this.i18n('Delete'),
238 handler: () => this.removeVideo(), 261 handler: () => this.removeVideo(),
239 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(), 262 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(),
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html
index 46c49c15b..8e948ce42 100644
--- a/client/src/app/shared/video/video-miniature.component.html
+++ b/client/src/app/shared/video/video-miniature.component.html
@@ -2,7 +2,10 @@
2 <my-video-thumbnail 2 <my-video-thumbnail
3 [video]="video" [nsfw]="isVideoBlur" 3 [video]="video" [nsfw]="isVideoBlur"
4 [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)" 4 [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
5 ></my-video-thumbnail> 5 >
6 <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
7 <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
8 </my-video-thumbnail>
6 9
7 <div class="video-bottom"> 10 <div class="video-bottom">
8 <div class="video-miniature-information"> 11 <div class="video-miniature-information">
@@ -19,11 +22,6 @@
19 <ng-container *ngIf="displayOptions.date && displayOptions.views"> • </ng-container> 22 <ng-container *ngIf="displayOptions.date && displayOptions.views"> • </ng-container>
20 <ng-container i18n *ngIf="displayOptions.views">{video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}}</ng-container> 23 <ng-container i18n *ngIf="displayOptions.views">{video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}}</ng-container>
21 </span> 24 </span>
22
23 <ng-container *ngIf="displayOptions.privacyLabel">
24 <span *ngIf="isUnlistedVideo()" class="badge badge-warning ml-1" i18n>Unlisted</span>
25 <span *ngIf="isPrivateVideo()" class="badge badge-danger ml-1" i18n>Private</span>
26 </ng-container>
27 </span> 25 </span>
28 26
29 <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]"> 27 <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]">
@@ -50,9 +48,9 @@
50 </div> 48 </div>
51 49
52 <div class="video-actions"> 50 <div class="video-actions">
53 <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown --> 51 <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown: https://github.com/ng-bootstrap/ng-bootstrap/issues/3495 -->
54 <my-video-actions-dropdown 52 <my-video-actions-dropdown
55 *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left" 53 *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left auto"
56 (videoRemoved)="onVideoRemoved()" (videoBlacklisted)="onVideoBlacklisted()" (videoUnblacklisted)="onVideoUnblacklisted()" 54 (videoRemoved)="onVideoRemoved()" (videoBlacklisted)="onVideoBlacklisted()" (videoUnblacklisted)="onVideoUnblacklisted()"
57 ></my-video-actions-dropdown> 55 ></my-video-actions-dropdown>
58 </div> 56 </div>
diff --git a/client/src/app/shared/video/video-miniature.component.scss b/client/src/app/shared/video/video-miniature.component.scss
index b63fd2989..f27800a24 100644
--- a/client/src/app/shared/video/video-miniature.component.scss
+++ b/client/src/app/shared/video/video-miniature.component.scss
@@ -85,8 +85,12 @@ $more-margin-right: 15px;
85 } 85 }
86 86
87 @media screen and (max-width: $small-view) { 87 @media screen and (max-width: $small-view) {
88 .video-miniature-information .video-miniature-name { 88 .video-miniature-information {
89 margin-top: 0; 89 margin: 0 10px;
90
91 .video-miniature-name {
92 margin-top: 0;
93 }
90 } 94 }
91 95
92 .video-actions { 96 .video-actions {
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts
index 598a7a983..72b652448 100644
--- a/client/src/app/shared/video/video-miniature.component.ts
+++ b/client/src/app/shared/video/video-miniature.component.ts
@@ -64,7 +64,8 @@ export class VideoMiniatureComponent implements OnInit {
64 update: true, 64 update: true,
65 blacklist: true, 65 blacklist: true,
66 delete: true, 66 delete: true,
67 report: true 67 report: true,
68 duplicate: true
68 } 69 }
69 showActions = false 70 showActions = false
70 serverConfig: ServerConfig 71 serverConfig: ServerConfig
@@ -199,7 +200,7 @@ export class VideoMiniatureComponent implements OnInit {
199 } 200 }
200 201
201 isWatchLaterPlaylistDisplayed () { 202 isWatchLaterPlaylistDisplayed () {
202 return this.inWatchLaterPlaylist !== undefined 203 return this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
203 } 204 }
204 205
205 private setUpBy () { 206 private setUpBy () {
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html
index b63085b81..fe5510c56 100644
--- a/client/src/app/shared/video/video-thumbnail.component.html
+++ b/client/src/app/shared/video/video-thumbnail.component.html
@@ -1,5 +1,5 @@
1<a 1<a
2 [routerLink]="getVideoRouterLink()" [queryParams]="queryParams" [title]="video.name" 2 [routerLink]="getVideoRouterLink()" [queryParams]="queryParams"
3 class="video-thumbnail" 3 class="video-thumbnail"
4> 4>
5 <img alt="" [attr.aria-label]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> 5 <img alt="" [attr.aria-label]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
@@ -18,6 +18,9 @@
18 </ng-container> 18 </ng-container>
19 </div> 19 </div>
20 20
21 <div class="video-thumbnail-label-overlay warning"><ng-content select="label-warning"></ng-content></div>
22 <div class="video-thumbnail-label-overlay danger"><ng-content select="label-danger"></ng-content></div>
23
21 <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div> 24 <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div>
22 25
23 <div class="play-overlay"> 26 <div class="play-overlay">
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss
index 573a64987..5fca916f0 100644
--- a/client/src/app/shared/video/video-thumbnail.component.scss
+++ b/client/src/app/shared/video/video-thumbnail.component.scss
@@ -19,13 +19,24 @@
19 } 19 }
20 20
21 .video-thumbnail-watch-later-overlay, 21 .video-thumbnail-watch-later-overlay,
22 .video-thumbnail-label-overlay,
22 .video-thumbnail-duration-overlay { 23 .video-thumbnail-duration-overlay {
23 @include static-thumbnail-overlay; 24 @include static-thumbnail-overlay;
24 25
25 border-radius: 3px; 26 border-radius: 3px;
26 font-size: 12px; 27 font-size: 12px;
27 font-weight: $font-bold; 28 font-weight: $font-bold;
28 z-index: 1; 29 z-index: z(miniature);
30 }
31
32 .video-thumbnail-label-overlay {
33 position: absolute;
34 padding: 0 5px;
35 left: 5px;
36 top: 5px;
37
38 &.warning { background-color: orange; }
39 &.danger { background-color: red; }
29 } 40 }
30 41
31 .video-thumbnail-duration-overlay { 42 .video-thumbnail-duration-overlay {
diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts
index 2420ec715..111b4c8bb 100644
--- a/client/src/app/shared/video/video-thumbnail.component.ts
+++ b/client/src/app/shared/video/video-thumbnail.component.ts
@@ -12,7 +12,7 @@ export class VideoThumbnailComponent {
12 @Input() video: Video 12 @Input() video: Video
13 @Input() nsfw = false 13 @Input() nsfw = false
14 @Input() routerLink: any[] 14 @Input() routerLink: any[]
15 @Input() queryParams: any[] 15 @Input() queryParams: { [ p: string ]: any }
16 16
17 @Input() displayWatchLaterPlaylist: boolean 17 @Input() displayWatchLaterPlaylist: boolean
18 @Input() inWatchLaterPlaylist: boolean 18 @Input() inWatchLaterPlaylist: boolean
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index fb98d5382..546518cca 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -42,6 +42,9 @@ export class Video implements VideoServerModel {
42 dislikes: number 42 dislikes: number
43 nsfw: boolean 43 nsfw: boolean
44 44
45 originInstanceUrl: string
46 originInstanceHost: string
47
45 waitTranscoding?: boolean 48 waitTranscoding?: boolean
46 state?: VideoConstant<VideoState> 49 state?: VideoConstant<VideoState>
47 scheduledUpdate?: VideoScheduleUpdate 50 scheduledUpdate?: VideoScheduleUpdate
@@ -86,22 +89,31 @@ export class Video implements VideoServerModel {
86 this.waitTranscoding = hash.waitTranscoding 89 this.waitTranscoding = hash.waitTranscoding
87 this.state = hash.state 90 this.state = hash.state
88 this.description = hash.description 91 this.description = hash.description
92
89 this.duration = hash.duration 93 this.duration = hash.duration
90 this.durationLabel = durationToString(hash.duration) 94 this.durationLabel = durationToString(hash.duration)
95
91 this.id = hash.id 96 this.id = hash.id
92 this.uuid = hash.uuid 97 this.uuid = hash.uuid
98
93 this.isLocal = hash.isLocal 99 this.isLocal = hash.isLocal
94 this.name = hash.name 100 this.name = hash.name
101
95 this.thumbnailPath = hash.thumbnailPath 102 this.thumbnailPath = hash.thumbnailPath
96 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath 103 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
104
97 this.previewPath = hash.previewPath 105 this.previewPath = hash.previewPath
98 this.previewUrl = absoluteAPIUrl + hash.previewPath 106 this.previewUrl = absoluteAPIUrl + hash.previewPath
107
99 this.embedPath = hash.embedPath 108 this.embedPath = hash.embedPath
100 this.embedUrl = absoluteAPIUrl + hash.embedPath 109 this.embedUrl = absoluteAPIUrl + hash.embedPath
110
101 this.views = hash.views 111 this.views = hash.views
102 this.likes = hash.likes 112 this.likes = hash.likes
103 this.dislikes = hash.dislikes 113 this.dislikes = hash.dislikes
114
104 this.nsfw = hash.nsfw 115 this.nsfw = hash.nsfw
116
105 this.account = hash.account 117 this.account = hash.account
106 this.channel = hash.channel 118 this.channel = hash.channel
107 119
@@ -124,6 +136,9 @@ export class Video implements VideoServerModel {
124 this.blacklistedReason = hash.blacklistedReason 136 this.blacklistedReason = hash.blacklistedReason
125 137
126 this.userHistory = hash.userHistory 138 this.userHistory = hash.userHistory
139
140 this.originInstanceHost = this.account.host
141 this.originInstanceUrl = 'https://' + this.originInstanceHost
127 } 142 }
128 143
129 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { 144 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
@@ -152,4 +167,8 @@ export class Video implements VideoServerModel {
152 isUpdatableBy (user: AuthUser) { 167 isUpdatableBy (user: AuthUser) {
153 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) 168 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO))
154 } 169 }
170
171 canBeDuplicatedBy (user: AuthUser) {
172 return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
173 }
155} 174}
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index 996202154..3aaf14990 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -27,10 +27,12 @@ import { objectToFormData } from '@app/shared/misc/utils'
27import { Account } from '@app/shared/account/account.model' 27import { Account } from '@app/shared/account/account.model'
28import { AccountService } from '@app/shared/account/account.service' 28import { AccountService } from '@app/shared/account/account.service'
29import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 29import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
30import { ServerService } from '@app/core' 30import { ServerService, AuthService } from '@app/core'
31import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' 31import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
32import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 32import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
33import { I18n } from '@ngx-translate/i18n-polyfill' 33import { I18n } from '@ngx-translate/i18n-polyfill'
34import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
35import { FfprobeData } from 'fluent-ffmpeg'
34 36
35export interface VideosProvider { 37export interface VideosProvider {
36 getVideos (parameters: { 38 getVideos (parameters: {
@@ -49,6 +51,8 @@ export class VideoService implements VideosProvider {
49 51
50 constructor ( 52 constructor (
51 private authHttp: HttpClient, 53 private authHttp: HttpClient,
54 private authService: AuthService,
55 private userService: UserService,
52 private restExtractor: RestExtractor, 56 private restExtractor: RestExtractor,
53 private restService: RestService, 57 private restService: RestService,
54 private serverService: ServerService, 58 private serverService: ServerService,
@@ -199,9 +203,10 @@ export class VideoService implements VideosProvider {
199 filter?: VideoFilter, 203 filter?: VideoFilter,
200 categoryOneOf?: number, 204 categoryOneOf?: number,
201 languageOneOf?: string[], 205 languageOneOf?: string[],
202 skipCount?: boolean 206 skipCount?: boolean,
207 nsfw?: boolean
203 }): Observable<ResultList<Video>> { 208 }): Observable<ResultList<Video>> {
204 const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount } = parameters 209 const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfw } = parameters
205 210
206 const pagination = this.restService.componentPaginationToRestPagination(videoPagination) 211 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
207 212
@@ -212,6 +217,15 @@ export class VideoService implements VideosProvider {
212 if (categoryOneOf) params = params.set('categoryOneOf', categoryOneOf + '') 217 if (categoryOneOf) params = params.set('categoryOneOf', categoryOneOf + '')
213 if (skipCount) params = params.set('skipCount', skipCount + '') 218 if (skipCount) params = params.set('skipCount', skipCount + '')
214 219
220 if (nsfw) {
221 params = params.set('nsfw', nsfw + '')
222 } else {
223 const nsfwPolicy = this.authService.isLoggedIn()
224 ? this.authService.getUser().nsfwPolicy
225 : this.userService.getAnonymousUser().nsfwPolicy
226 if (this.nsfwPolicyToFilter(nsfwPolicy)) params.set('nsfw', 'false')
227 }
228
215 if (languageOneOf) { 229 if (languageOneOf) {
216 for (const l of languageOneOf) { 230 for (const l of languageOneOf) {
217 params = params.append('languageOneOf[]', l) 231 params = params.append('languageOneOf[]', l)
@@ -278,6 +292,14 @@ export class VideoService implements VideosProvider {
278 return this.buildBaseFeedUrls(params) 292 return this.buildBaseFeedUrls(params)
279 } 293 }
280 294
295 getVideoFileMetadata (metadataUrl: string) {
296 return this.authHttp
297 .get<FfprobeData>(metadataUrl)
298 .pipe(
299 catchError(err => this.restExtractor.handleError(err))
300 )
301 }
302
281 removeVideo (id: number) { 303 removeVideo (id: number) {
282 return this.authHttp 304 return this.authHttp
283 .delete(VideoService.BASE_VIDEO_URL + id) 305 .delete(VideoService.BASE_VIDEO_URL + id)
@@ -368,4 +390,8 @@ export class VideoService implements VideosProvider {
368 catchError(err => this.restExtractor.handleError(err)) 390 catchError(err => this.restExtractor.handleError(err))
369 ) 391 )
370 } 392 }
393
394 private nsfwPolicyToFilter (policy: NSFWPolicyType) {
395 return policy === 'do_not_list'
396 }
371} 397}
diff --git a/client/src/app/shared/video/videos-selection.component.ts b/client/src/app/shared/video/videos-selection.component.ts
index 064420056..17e5beb24 100644
--- a/client/src/app/shared/video/videos-selection.component.ts
+++ b/client/src/app/shared/video/videos-selection.component.ts
@@ -22,6 +22,8 @@ import { VideoSortField } from '@app/shared/video/sort-field.type'
22import { ComponentPagination } from '@app/shared/rest/component-pagination.model' 22import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
23import { I18n } from '@ngx-translate/i18n-polyfill' 23import { I18n } from '@ngx-translate/i18n-polyfill'
24import { ResultList } from '@shared/models' 24import { ResultList } from '@shared/models'
25import { UserService } from '../users'
26import { LocalStorageService } from '../misc/storage.service'
25 27
26export type SelectionType = { [ id: number ]: boolean } 28export type SelectionType = { [ id: number ]: boolean }
27 29
@@ -51,7 +53,9 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
51 protected route: ActivatedRoute, 53 protected route: ActivatedRoute,
52 protected notifier: Notifier, 54 protected notifier: Notifier,
53 protected authService: AuthService, 55 protected authService: AuthService,
56 protected userService: UserService,
54 protected screenService: ScreenService, 57 protected screenService: ScreenService,
58 protected storageService: LocalStorageService,
55 protected serverService: ServerService 59 protected serverService: ServerService
56 ) { 60 ) {
57 super() 61 super()