diff options
Diffstat (limited to 'client/src/app/shared')
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 @@ | |||
1 | import { PipeTransform, Pipe } from '@angular/core' | ||
2 | import { SafeHtml } from '@angular/platform-browser' | ||
3 | |||
4 | // Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369 | ||
5 | @Pipe({ name: 'highlight' }) | ||
6 | export 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 @@ | |||
1 | import { Pipe, PipeTransform } from '@angular/core' | 1 | import { Pipe, PipeTransform } from '@angular/core' |
2 | 2 | import { 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 | }) |
8 | export class VideoDurationPipe implements PipeTransform { | 7 | export 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 | ||
14 | export type DropdownButtonSize = 'normal' | 'small' | 15 | export 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 | ||
9 | export class EditButtonComponent { | 9 | export 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 @@ | |||
1 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' | 1 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' |
2 | import { Component, forwardRef, Input, OnInit } from '@angular/core' | 2 | import { Component, forwardRef, Input, OnInit, ViewChild, ElementRef } from '@angular/core' |
3 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | 3 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' |
4 | import { Subject } from 'rxjs' | 4 | import { Subject } from 'rxjs' |
5 | import truncate from 'lodash-es/truncate' | 5 | import truncate from 'lodash-es/truncate' |
@@ -21,19 +21,19 @@ import { MarkdownService } from '@app/shared/renderer' | |||
21 | 21 | ||
22 | export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { | 22 | export 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 | |||
1 | p-inputmask { | 3 | p-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 @@ | |||
1 | import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core' | 1 | import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core' |
2 | import { HooksService } from '@app/core/plugins/hooks.service' | 2 | import { HooksService } from '@app/core/plugins/hooks.service' |
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
4 | 3 | ||
5 | const icons = { | 4 | const 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 | ||
57 | export type GlobalIconName = keyof typeof icons | 62 | export 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' | |||
3 | import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' | 3 | import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' |
4 | import { ServerService } from '@app/core' | 4 | import { ServerService } from '@app/core' |
5 | import { ServerConfig } from '@shared/models' | 5 | import { ServerConfig } from '@shared/models' |
6 | import { BytesPipe } from 'ngx-pipes' | ||
7 | import { 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 @@ | |||
1 | import { map } from 'rxjs/operators' | ||
2 | import { HttpClient } from '@angular/common/http' | ||
3 | import { Component, OnInit } from '@angular/core' | 1 | import { Component, OnInit } from '@angular/core' |
4 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
5 | import { ServerStats } from '@shared/models/server' | 2 | import { ServerStats } from '@shared/models/server' |
6 | import { environment } from '../../../environments/environment' | 3 | import { 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 | }) |
13 | export class InstanceStatisticsComponent implements OnInit { | 10 | export 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 @@ | |||
1 | import { Component, Input, OnDestroy, OnInit } from '@angular/core' | 1 | import { |
2 | Component, | ||
3 | Input, | ||
4 | OnDestroy, | ||
5 | OnInit, | ||
6 | ViewChild | ||
7 | } from '@angular/core' | ||
2 | import { filter, take } from 'rxjs/operators' | 8 | import { filter, take } from 'rxjs/operators' |
3 | import { NavigationEnd, Router } from '@angular/router' | 9 | import { NavigationEnd, Router } from '@angular/router' |
4 | import { Subscription } from 'rxjs' | 10 | import { Subscription } from 'rxjs' |
5 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' | 11 | import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap' |
6 | import { GlobalIconName } from '@app/shared/images/global-icon.component' | 12 | import { GlobalIconName } from '@app/shared/images/global-icon.component' |
7 | import { ScreenService } from '@app/shared/misc/screen.service' | 13 | import { ScreenService } from '@app/shared/misc/screen.service' |
8 | 14 | ||
@@ -26,32 +32,40 @@ export type TopMenuDropdownParam = { | |||
26 | export class TopMenuDropdownComponent implements OnInit, OnDestroy { | 32 | export 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 | |||
16 | button { | ||
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 @@ | |||
1 | import { | ||
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' | ||
14 | import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
15 | import { lowerFirst, uniqueId } from 'lodash-es' | ||
16 | import { ScreenService } from './screen.service' | ||
17 | import { take } from 'rxjs/operators' | ||
18 | |||
19 | export 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 | }) | ||
30 | export 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 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { Observable, Subject } from 'rxjs' | ||
3 | import { | ||
4 | peertubeLocalStorage, | ||
5 | peertubeSessionStorage | ||
6 | } from './peertube-web-storage' | ||
7 | import { filter } from 'rxjs/operators' | ||
8 | |||
9 | abstract 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() | ||
33 | export class LocalStorageService extends StorageService { | ||
34 | protected instance: Storage = peertubeLocalStorage | ||
35 | } | ||
36 | |||
37 | @Injectable() | ||
38 | export 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 | }) |
16 | export class UserModerationDropdownComponent implements OnInit, OnChanges { | 16 | export 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 @@ | |||
1 | import { catchError, map, switchMap, tap } from 'rxjs/operators' | 1 | import { catchError, map, switchMap, tap } from 'rxjs/operators' |
2 | import { HttpClient } from '@angular/common/http' | 2 | import { HttpClient, HttpParams } from '@angular/common/http' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { forkJoin, Observable, of } from 'rxjs' | 4 | import { forkJoin, Observable, of } from 'rxjs' |
5 | import { VideosOverview as VideosOverviewServer, peertubeTranslate } from '../../../../../shared/models' | 5 | import { 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 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { Injectable } from '@angular/core' |
2 | import { MarkdownIt } from 'markdown-it' | ||
3 | import { buildVideoLink } from '../../../assets/player/utils' | 2 | import { buildVideoLink } from '../../../assets/player/utils' |
4 | import { HtmlRendererService } from '@app/shared/renderer/html-renderer.service' | 3 | import { HtmlRendererService } from '@app/shared/renderer/html-renderer.service' |
4 | import * as MarkdownIt from 'markdown-it' | ||
5 | 5 | ||
6 | type MarkdownParsers = { | 6 | type 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 @@ | |||
1 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' | 1 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' |
2 | import { LazyLoadEvent } from 'primeng/components/common/lazyloadevent' | 2 | import { LazyLoadEvent, SortMeta } from 'primeng/api' |
3 | import { SortMeta } from 'primeng/components/common/sortmeta' | ||
4 | import { RestPagination } from './rest-pagination' | 3 | import { RestPagination } from './rest-pagination' |
5 | import { Subject } from 'rxjs' | 4 | import { Subject } from 'rxjs' |
6 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' | 5 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' |
@@ -8,13 +7,17 @@ import { debounceTime, distinctUntilChanged } from 'rxjs/operators' | |||
8 | export abstract class RestTable { | 7 | export 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 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { SortMeta } from 'primeng/api' |
2 | import { HttpParams } from '@angular/common/http' | 2 | import { HttpParams } from '@angular/common/http' |
3 | import { SortMeta } from 'primeng/components/common/sortmeta' | 3 | import { Injectable } from '@angular/core' |
4 | import { ComponentPagination, ComponentPaginationLight } from './component-pagination.model' | 4 | import { ComponentPaginationLight } from './component-pagination.model' |
5 | |||
6 | import { RestPagination } from './rest-pagination' | 5 | import { RestPagination } from './rest-pagination' |
7 | 6 | ||
7 | interface QueryStringFilterPrefixes { | ||
8 | [key: string]: { | ||
9 | prefix: string | ||
10 | handler?: (v: string) => string | number | ||
11 | multiple?: boolean | ||
12 | } | ||
13 | } | ||
14 | |||
15 | type ParseQueryStringFilterResult = { | ||
16 | [key: string]: string | number | (string | number)[] | ||
17 | } | ||
18 | |||
8 | @Injectable() | 19 | @Injectable() |
9 | export class RestService { | 20 | export 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' | |||
5 | import { RouterModule } from '@angular/router' | 5 | import { RouterModule } from '@angular/router' |
6 | import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component' | 6 | import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component' |
7 | import { HelpComponent } from '@app/shared/misc/help.component' | 7 | import { HelpComponent } from '@app/shared/misc/help.component' |
8 | import { ListOverflowComponent } from '@app/shared/misc/list-overflow.component' | ||
8 | import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' | 9 | import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' |
9 | import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' | 10 | import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' |
10 | import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' | 11 | import { SharedModule as PrimeSharedModule } from 'primeng/api' |
11 | import { AUTH_INTERCEPTOR_PROVIDER } from './auth' | 12 | import { AUTH_INTERCEPTOR_PROVIDER } from './auth' |
12 | import { ButtonComponent } from './buttons/button.component' | 13 | import { ButtonComponent } from './buttons/button.component' |
13 | import { DeleteButtonComponent } from './buttons/delete-button.component' | 14 | import { DeleteButtonComponent } from './buttons/delete-button.component' |
@@ -46,6 +47,7 @@ import { | |||
46 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' | 47 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' |
47 | import { InputMaskModule } from 'primeng/inputmask' | 48 | import { InputMaskModule } from 'primeng/inputmask' |
48 | import { ScreenService } from '@app/shared/misc/screen.service' | 49 | import { ScreenService } from '@app/shared/misc/screen.service' |
50 | import { LocalStorageService, SessionStorageService } from '@app/shared/misc/storage.service' | ||
49 | import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' | 51 | import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' |
50 | import { VideoCaptionService } from '@app/shared/video-caption' | 52 | import { VideoCaptionService } from '@app/shared/video-caption' |
51 | import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component' | 53 | import { 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' |
62 | import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription' | 64 | import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription' |
@@ -88,16 +90,24 @@ import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe' | |||
88 | import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe' | 90 | import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe' |
89 | import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe' | 91 | import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe' |
90 | import { FromNowPipe } from '@app/shared/angular/from-now.pipe' | 92 | import { FromNowPipe } from '@app/shared/angular/from-now.pipe' |
93 | import { HighlightPipe } from '@app/shared/angular/highlight.pipe' | ||
91 | import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' | 94 | import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' |
92 | import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component' | 95 | import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component' |
93 | import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component' | 96 | import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component' |
94 | import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component' | 97 | import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component' |
95 | import { VideoReportComponent } from '@app/shared/video/modals/video-report.component' | 98 | import { VideoReportComponent } from '@app/shared/video/modals/video-report.component' |
96 | import { ClipboardModule } from 'ngx-clipboard' | ||
97 | import { FollowService } from '@app/shared/instance/follow.service' | 99 | import { FollowService } from '@app/shared/instance/follow.service' |
98 | import { MultiSelectModule } from 'primeng/multiselect' | 100 | import { MultiSelectModule } from 'primeng/multiselect' |
99 | import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component' | 101 | import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component' |
100 | import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component' | 102 | import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component' |
103 | import { RedundancyService } from '@app/shared/video/redundancy.service' | ||
104 | import { ClipboardModule } from '@angular/cdk/clipboard' | ||
105 | import { InputSwitchModule } from 'primeng/inputswitch' | ||
106 | |||
107 | import { MyAccountVideoSettingsComponent } from '@app/+my-account/my-account-settings/my-account-video-settings' | ||
108 | import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account-settings/my-account-interface' | ||
109 | import { ActorAvatarInfoComponent } from '@app/+my-account/shared/actor-avatar-info.component' | ||
110 | import { 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 @@ | |||
1 | import { hasUserRight, User as UserServerModel, UserNotificationSetting, UserRight, UserRole, VideoChannel } from '../../../../../shared' | 1 | import { |
2 | hasUserRight, | ||
3 | User as UserServerModel, | ||
4 | UserNotificationSetting, | ||
5 | UserRight, | ||
6 | UserRole | ||
7 | } from '../../../../../shared/models/users' | ||
8 | import { VideoChannel } from '../../../../../shared/models/videos' | ||
2 | import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' | 9 | import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' |
3 | import { Account } from '@app/shared/account/account.model' | 10 | import { Account } from '@app/shared/account/account.model' |
4 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' | 11 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' |
5 | import { UserAdminFlag } from '@shared/models/users/user-flag.model' | 12 | import { UserAdminFlag } from '@shared/models/users/user-flag.model' |
6 | 13 | ||
7 | export class User implements UserServerModel { | 14 | export 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 @@ | |||
1 | import { from, Observable, of } from 'rxjs' | 1 | import { from, Observable } from 'rxjs' |
2 | import { catchError, concatMap, map, share, shareReplay, tap, toArray } from 'rxjs/operators' | 2 | import { catchError, concatMap, map, shareReplay, toArray } from 'rxjs/operators' |
3 | import { HttpClient, HttpParams } from '@angular/common/http' | 3 | import { HttpClient, HttpParams } from '@angular/common/http' |
4 | import { Injectable } from '@angular/core' | 4 | import { Injectable } from '@angular/core' |
5 | import { ResultList, User, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' | 5 | import { ResultList, User as UserServerModel, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' |
6 | import { environment } from '../../../environments/environment' | 6 | import { environment } from '../../../environments/environment' |
7 | import { RestExtractor, RestPagination, RestService } from '../rest' | 7 | import { RestExtractor, RestPagination, RestService } from '../rest' |
8 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' | 8 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' |
@@ -10,6 +10,10 @@ import { SortMeta } from 'primeng/api' | |||
10 | import { BytesPipe } from 'ngx-pipes' | 10 | import { BytesPipe } from 'ngx-pipes' |
11 | import { I18n } from '@ngx-translate/i18n-polyfill' | 11 | import { I18n } from '@ngx-translate/i18n-polyfill' |
12 | import { UserRegister } from '@shared/models/users/user-register.model' | 12 | import { UserRegister } from '@shared/models/users/user-register.model' |
13 | import { User } from './user.model' | ||
14 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' | ||
15 | import { has } from 'lodash-es' | ||
16 | import { LocalStorageService, SessionStorageService } from '../misc/storage.service' | ||
13 | 17 | ||
14 | @Injectable() | 18 | @Injectable() |
15 | export class UserService { | 19 | export 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 @@ | |||
1 | import { catchError, map } from 'rxjs/operators' | 1 | import { catchError, map } from 'rxjs/operators' |
2 | import { HttpClient, HttpParams } from '@angular/common/http' | 2 | import { HttpClient, HttpParams } from '@angular/common/http' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { SortMeta } from 'primeng/components/common/sortmeta' | 4 | import { SortMeta } from 'primeng/api' |
5 | import { Observable } from 'rxjs' | 5 | import { Observable } from 'rxjs' |
6 | import { ResultList, VideoAbuse, VideoAbuseUpdate } from '../../../../../shared' | 6 | import { ResultList, VideoAbuse, VideoAbuseUpdate, VideoAbuseState } from '../../../../../shared' |
7 | import { environment } from '../../../environments/environment' | 7 | import { environment } from '../../../environments/environment' |
8 | import { RestExtractor, RestPagination, RestService } from '../rest' | 8 | import { 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 @@ | |||
1 | import { catchError, map, concatMap, toArray } from 'rxjs/operators' | 1 | import { catchError, map, concatMap, toArray } from 'rxjs/operators' |
2 | import { HttpClient, HttpParams } from '@angular/common/http' | 2 | import { HttpClient, HttpParams } from '@angular/common/http' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { SortMeta } from 'primeng/components/common/sortmeta' | 4 | import { SortMeta } from 'primeng/api' |
5 | import { from as observableFrom, Observable } from 'rxjs' | 5 | import { from as observableFrom, Observable } from 'rxjs' |
6 | import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared' | 6 | import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared' |
7 | import { Video } from '../video/video.model' | 7 | import { 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 @@ | |||
1 | import { VideoChannel as ServerVideoChannel } from '../../../../../shared/models/videos' | 1 | import { VideoChannel as ServerVideoChannel, ViewsPerDate } from '../../../../../shared/models/videos' |
2 | import { Actor } from '../actor/actor.model' | 2 | import { Actor } from '../actor/actor.model' |
3 | import { Account } from '../../../../../shared/models/actors' | 3 | import { 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 | |||
9 | import { objectToFormData } from '@app/shared/misc/utils' | 9 | import { objectToFormData } from '@app/shared/misc/utils' |
10 | import { ResultList } from '../../../../../shared/models/result-list.model' | 10 | import { ResultList } from '../../../../../shared/models/result-list.model' |
11 | import { UserService } from '@app/shared/users/user.service' | 11 | import { UserService } from '@app/shared/users/user.service' |
12 | import { SortMeta } from 'primeng/components/common/sortmeta' | 12 | import { SortMeta } from 'primeng/api' |
13 | import { RestPagination } from '@app/shared/rest' | 13 | import { RestPagination } from '@app/shared/rest' |
14 | import { ServerService } from '@app/core' | 14 | import { 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' | |||
5 | import { RestExtractor, RestService } from '../rest' | 5 | import { RestExtractor, RestService } from '../rest' |
6 | import { VideoChangeOwnershipCreate } from '../../../../../shared/models/videos' | 6 | import { VideoChangeOwnershipCreate } from '../../../../../shared/models/videos' |
7 | import { Observable } from 'rxjs/index' | 7 | import { Observable } from 'rxjs/index' |
8 | import { SortMeta } from 'primeng/components/common/sortmeta' | 8 | import { SortMeta } from 'primeng/api' |
9 | import { ResultList, VideoChangeOwnership } from '../../../../../shared' | 9 | import { ResultList, VideoChangeOwnership } from '../../../../../shared' |
10 | import { RestPagination } from '@app/shared/rest' | 10 | import { RestPagination } from '@app/shared/rest' |
11 | import { VideoChangeOwnershipAccept } from '../../../../../shared/models/videos/video-change-ownership-accept.model' | 11 | import { 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 | }) |
20 | export class VideoPlaylistElementMiniatureComponent implements OnInit { | 20 | export 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 @@ | |||
1 | import { debounceTime, first, tap } from 'rxjs/operators' | 1 | import { debounceTime, first, tap, throttleTime } from 'rxjs/operators' |
2 | import { OnDestroy, OnInit } from '@angular/core' | 2 | import { OnDestroy, OnInit } from '@angular/core' |
3 | import { ActivatedRoute, Router } from '@angular/router' | 3 | import { ActivatedRoute, Router } from '@angular/router' |
4 | import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs' | 4 | import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs' |
@@ -14,6 +14,9 @@ import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' | |||
14 | import { I18n } from '@ngx-translate/i18n-polyfill' | 14 | import { I18n } from '@ngx-translate/i18n-polyfill' |
15 | import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date' | 15 | import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date' |
16 | import { ServerConfig } from '@shared/models' | 16 | import { ServerConfig } from '@shared/models' |
17 | import { GlobalIconName } from '@app/shared/images/global-icon.component' | ||
18 | import { UserService, User } from '../users' | ||
19 | import { LocalStorageService } from '../misc/storage.service' | ||
17 | 20 | ||
18 | enum GroupDate { | 21 | enum 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 @@ | |||
1 | import { distinctUntilChanged, filter, map, share, startWith, tap, throttleTime } from 'rxjs/operators' | 1 | import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' |
2 | import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' | 2 | import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' |
3 | import { fromEvent, Observable, Subscription } from 'rxjs' | 3 | import { 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 @@ | |||
1 | import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | 1 | import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' |
2 | import { Notifier, RedirectService } from '@app/core' | 2 | import { Notifier, RedirectService } from '@app/core' |
3 | import { VideoBlacklistService } from '../../../shared/video-blacklist' | 3 | import { VideoBlacklistService } from '../../../shared/video-blacklist' |
4 | import { VideoDetails } from '../../../shared/video/video-details.model' | ||
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | 4 | import { I18n } from '@ngx-translate/i18n-polyfill' |
6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 5 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
7 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 6 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
8 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | 7 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' |
9 | import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms' | 8 | import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms' |
9 | import { 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 | }) |
16 | export class VideoBlacklistComponent extends FormReactive implements OnInit { | 16 | export 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' | |||
3 | import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' | 3 | import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' |
4 | import { I18n } from '@ngx-translate/i18n-polyfill' | 4 | import { I18n } from '@ngx-translate/i18n-polyfill' |
5 | import { AuthService, Notifier } from '@app/core' | 5 | import { AuthService, Notifier } from '@app/core' |
6 | import { VideoPrivacy, VideoCaption } from '@shared/models' | 6 | import { VideoPrivacy, VideoCaption, VideoFile } from '@shared/models' |
7 | import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg' | ||
8 | import { mapValues, pick } from 'lodash-es' | ||
9 | import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe' | ||
10 | import { BytesPipe } from 'ngx-pipes' | ||
11 | import { VideoService } from '../video.service' | ||
7 | 12 | ||
8 | type DownloadType = 'video' | 'subtitles' | 13 | type DownloadType = 'video' | 'subtitles' |
14 | type 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 @@ | |||
1 | import { Component, Input, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, Input, OnInit, ViewChild } from '@angular/core' |
2 | import { Notifier } from '@app/core' | 2 | import { Notifier } from '@app/core' |
3 | import { FormReactive } from '../../../shared/forms' | 3 | import { FormReactive } from '../../../shared/forms' |
4 | import { VideoDetails } from '../../../shared/video/video-details.model' | ||
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | 4 | import { I18n } from '@ngx-translate/i18n-polyfill' |
6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 5 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
7 | import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' | 6 | import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' |
8 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 7 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
9 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | 8 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' |
10 | import { VideoAbuseService } from '@app/shared/video-abuse' | 9 | import { VideoAbuseService } from '@app/shared/video-abuse' |
10 | import { 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 | }) |
17 | export class VideoReportComponent extends FormReactive implements OnInit { | 17 | export 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 @@ | |||
1 | import { catchError, map, toArray } from 'rxjs/operators' | ||
2 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { RestExtractor, RestPagination, RestService } from '@app/shared/rest' | ||
5 | import { SortMeta } from 'primeng/api' | ||
6 | import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models' | ||
7 | import { concat, Observable } from 'rxjs' | ||
8 | import { environment } from '../../../environments/environment' | ||
9 | |||
10 | @Injectable() | ||
11 | export 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 @@ | |||
1 | import { AfterViewInit, Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' | 1 | import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' |
2 | import { I18n } from '@ngx-translate/i18n-polyfill' | 2 | import { I18n } from '@ngx-translate/i18n-polyfill' |
3 | import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component' | 3 | import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component' |
4 | import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' | 4 | import { AuthService, ConfirmService, Notifier } from '@app/core' |
5 | import { BlocklistService } from '@app/shared/blocklist' | ||
6 | import { Video } from '@app/shared/video/video.model' | 5 | import { Video } from '@app/shared/video/video.model' |
7 | import { VideoService } from '@app/shared/video/video.service' | 6 | import { VideoService } from '@app/shared/video/video.service' |
8 | import { VideoDetails } from '@app/shared/video/video-details.model' | 7 | import { VideoDetails } from '@app/shared/video/video-details.model' |
@@ -14,6 +13,7 @@ import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklis | |||
14 | import { VideoBlacklistService } from '@app/shared/video-blacklist' | 13 | import { VideoBlacklistService } from '@app/shared/video-blacklist' |
15 | import { ScreenService } from '@app/shared/misc/screen.service' | 14 | import { ScreenService } from '@app/shared/misc/screen.service' |
16 | import { VideoCaption } from '@shared/models' | 15 | import { VideoCaption } from '@shared/models' |
16 | import { RedundancyService } from '@app/shared/video/redundancy.service' | ||
17 | 17 | ||
18 | export type VideoActionsDisplayType = { | 18 | export 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 | }) |
32 | export class VideoActionsDropdownComponent implements OnChanges { | 33 | export 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' | |||
27 | import { Account } from '@app/shared/account/account.model' | 27 | import { Account } from '@app/shared/account/account.model' |
28 | import { AccountService } from '@app/shared/account/account.service' | 28 | import { AccountService } from '@app/shared/account/account.service' |
29 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | 29 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' |
30 | import { ServerService } from '@app/core' | 30 | import { ServerService, AuthService } from '@app/core' |
31 | import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' | 31 | import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' |
32 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 32 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
33 | import { I18n } from '@ngx-translate/i18n-polyfill' | 33 | import { I18n } from '@ngx-translate/i18n-polyfill' |
34 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' | ||
35 | import { FfprobeData } from 'fluent-ffmpeg' | ||
34 | 36 | ||
35 | export interface VideosProvider { | 37 | export 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' | |||
22 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | 22 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' |
23 | import { I18n } from '@ngx-translate/i18n-polyfill' | 23 | import { I18n } from '@ngx-translate/i18n-polyfill' |
24 | import { ResultList } from '@shared/models' | 24 | import { ResultList } from '@shared/models' |
25 | import { UserService } from '../users' | ||
26 | import { LocalStorageService } from '../misc/storage.service' | ||
25 | 27 | ||
26 | export type SelectionType = { [ id: number ]: boolean } | 28 | export 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() |