diff options
Diffstat (limited to 'client/src/app/shared')
37 files changed, 1585 insertions, 743 deletions
diff --git a/client/src/app/shared/shared-forms/advanced-input-filter.component.ts b/client/src/app/shared/shared-forms/advanced-input-filter.component.ts index c11f1ad1d..72cd6d460 100644 --- a/client/src/app/shared/shared-forms/advanced-input-filter.component.ts +++ b/client/src/app/shared/shared-forms/advanced-input-filter.component.ts | |||
@@ -18,6 +18,7 @@ const logger = debug('peertube:AdvancedInputFilterComponent') | |||
18 | }) | 18 | }) |
19 | export class AdvancedInputFilterComponent implements OnInit, AfterViewInit { | 19 | export class AdvancedInputFilterComponent implements OnInit, AfterViewInit { |
20 | @Input() filters: AdvancedInputFilter[] = [] | 20 | @Input() filters: AdvancedInputFilter[] = [] |
21 | @Input() emitOnInit = true | ||
21 | 22 | ||
22 | @Output() search = new EventEmitter<string>() | 23 | @Output() search = new EventEmitter<string>() |
23 | 24 | ||
@@ -42,7 +43,7 @@ export class AdvancedInputFilterComponent implements OnInit, AfterViewInit { | |||
42 | this.viewInitialized = true | 43 | this.viewInitialized = true |
43 | 44 | ||
44 | // Init after view init to not send an event too early | 45 | // Init after view init to not send an event too early |
45 | if (this.emitSearchAfterViewInit) this.emitSearch() | 46 | if (this.emitOnInit && this.emitSearchAfterViewInit) this.emitSearch() |
46 | } | 47 | } |
47 | 48 | ||
48 | onInputSearch (event: Event) { | 49 | onInputSearch (event: Event) { |
diff --git a/client/src/app/shared/shared-forms/select/index.ts b/client/src/app/shared/shared-forms/select/index.ts index e387e1f48..a3d554ee2 100644 --- a/client/src/app/shared/shared-forms/select/index.ts +++ b/client/src/app/shared/shared-forms/select/index.ts | |||
@@ -1,5 +1,8 @@ | |||
1 | export * from './select-categories.component' | ||
1 | export * from './select-channel.component' | 2 | export * from './select-channel.component' |
3 | export * from './select-checkbox-all.component' | ||
2 | export * from './select-checkbox.component' | 4 | export * from './select-checkbox.component' |
3 | export * from './select-custom-value.component' | 5 | export * from './select-custom-value.component' |
6 | export * from './select-languages.component' | ||
4 | export * from './select-options.component' | 7 | export * from './select-options.component' |
5 | export * from './select-tags.component' | 8 | export * from './select-tags.component' |
diff --git a/client/src/app/shared/shared-forms/select/select-categories.component.html b/client/src/app/shared/shared-forms/select/select-categories.component.html new file mode 100644 index 000000000..2ec2f1264 --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-categories.component.html | |||
@@ -0,0 +1,8 @@ | |||
1 | <my-select-checkbox-all | ||
2 | [(ngModel)]="selectedCategories" | ||
3 | (ngModelChange)="onModelChange()" | ||
4 | [availableItems]="availableCategories" | ||
5 | i18n-placeholder placeholder="Add a new category" | ||
6 | [allGroupLabel]="allCategoriesGroup" | ||
7 | > | ||
8 | </my-select-checkbox-all> | ||
diff --git a/client/src/app/shared/shared-forms/select/select-categories.component.ts b/client/src/app/shared/shared-forms/select/select-categories.component.ts new file mode 100644 index 000000000..b921714ff --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-categories.component.ts | |||
@@ -0,0 +1,71 @@ | |||
1 | |||
2 | import { Component, forwardRef, OnInit } from '@angular/core' | ||
3 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
4 | import { ServerService } from '@app/core' | ||
5 | import { SelectOptionsItem } from '../../../../types/select-options-item.model' | ||
6 | import { ItemSelectCheckboxValue } from './select-checkbox.component' | ||
7 | |||
8 | @Component({ | ||
9 | selector: 'my-select-categories', | ||
10 | styleUrls: [ './select-shared.component.scss' ], | ||
11 | templateUrl: './select-categories.component.html', | ||
12 | providers: [ | ||
13 | { | ||
14 | provide: NG_VALUE_ACCESSOR, | ||
15 | useExisting: forwardRef(() => SelectCategoriesComponent), | ||
16 | multi: true | ||
17 | } | ||
18 | ] | ||
19 | }) | ||
20 | export class SelectCategoriesComponent implements ControlValueAccessor, OnInit { | ||
21 | selectedCategories: ItemSelectCheckboxValue[] = [] | ||
22 | availableCategories: SelectOptionsItem[] = [] | ||
23 | |||
24 | allCategoriesGroup = $localize`All categories` | ||
25 | |||
26 | // Fix a bug on ng-select when we update items after we selected items | ||
27 | private toWrite: any | ||
28 | private loaded = false | ||
29 | |||
30 | constructor ( | ||
31 | private server: ServerService | ||
32 | ) { | ||
33 | |||
34 | } | ||
35 | |||
36 | ngOnInit () { | ||
37 | this.server.getVideoCategories() | ||
38 | .subscribe( | ||
39 | categories => { | ||
40 | this.availableCategories = categories.map(c => ({ label: c.label, id: c.id + '', group: this.allCategoriesGroup })) | ||
41 | this.loaded = true | ||
42 | this.writeValue(this.toWrite) | ||
43 | } | ||
44 | ) | ||
45 | } | ||
46 | |||
47 | propagateChange = (_: any) => { /* empty */ } | ||
48 | |||
49 | writeValue (categories: string[] | number[]) { | ||
50 | if (!this.loaded) { | ||
51 | this.toWrite = categories | ||
52 | return | ||
53 | } | ||
54 | |||
55 | this.selectedCategories = categories | ||
56 | ? categories.map(c => c + '') | ||
57 | : categories as string[] | ||
58 | } | ||
59 | |||
60 | registerOnChange (fn: (_: any) => void) { | ||
61 | this.propagateChange = fn | ||
62 | } | ||
63 | |||
64 | registerOnTouched () { | ||
65 | // Unused | ||
66 | } | ||
67 | |||
68 | onModelChange () { | ||
69 | this.propagateChange(this.selectedCategories) | ||
70 | } | ||
71 | } | ||
diff --git a/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts b/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts new file mode 100644 index 000000000..ebf7b77a6 --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts | |||
@@ -0,0 +1,115 @@ | |||
1 | import { Component, forwardRef, Input } from '@angular/core' | ||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
3 | import { Notifier } from '@app/core' | ||
4 | import { SelectOptionsItem } from '../../../../types/select-options-item.model' | ||
5 | import { ItemSelectCheckboxValue } from './select-checkbox.component' | ||
6 | |||
7 | @Component({ | ||
8 | selector: 'my-select-checkbox-all', | ||
9 | styleUrls: [ './select-shared.component.scss' ], | ||
10 | |||
11 | template: ` | ||
12 | <my-select-checkbox | ||
13 | [(ngModel)]="selectedItems" | ||
14 | (ngModelChange)="onModelChange()" | ||
15 | [availableItems]="availableItems" | ||
16 | [selectableGroup]="true" [selectableGroupAsModel]="true" | ||
17 | [placeholder]="placeholder" | ||
18 | (focusout)="onBlur()" | ||
19 | > | ||
20 | </my-select-checkbox>`, | ||
21 | |||
22 | providers: [ | ||
23 | { | ||
24 | provide: NG_VALUE_ACCESSOR, | ||
25 | useExisting: forwardRef(() => SelectCheckboxAllComponent), | ||
26 | multi: true | ||
27 | } | ||
28 | ] | ||
29 | }) | ||
30 | export class SelectCheckboxAllComponent implements ControlValueAccessor { | ||
31 | @Input() availableItems: SelectOptionsItem[] = [] | ||
32 | @Input() allGroupLabel: string | ||
33 | |||
34 | @Input() placeholder: string | ||
35 | @Input() maxItems: number | ||
36 | |||
37 | selectedItems: ItemSelectCheckboxValue[] | ||
38 | |||
39 | constructor ( | ||
40 | private notifier: Notifier | ||
41 | ) { | ||
42 | |||
43 | } | ||
44 | |||
45 | propagateChange = (_: any) => { /* empty */ } | ||
46 | |||
47 | writeValue (items: string[]) { | ||
48 | this.selectedItems = items | ||
49 | ? items.map(l => ({ id: l })) | ||
50 | : [ { group: this.allGroupLabel } ] | ||
51 | } | ||
52 | |||
53 | registerOnChange (fn: (_: any) => void) { | ||
54 | this.propagateChange = fn | ||
55 | } | ||
56 | |||
57 | registerOnTouched () { | ||
58 | // Unused | ||
59 | } | ||
60 | |||
61 | onModelChange () { | ||
62 | if (!this.isMaxConstraintValid()) return | ||
63 | |||
64 | this.propagateChange(this.buildOutputItems()) | ||
65 | } | ||
66 | |||
67 | onBlur () { | ||
68 | // Automatically use "All languages" if the user did not select any language | ||
69 | if (Array.isArray(this.selectedItems) && this.selectedItems.length === 0) { | ||
70 | this.selectedItems = [ { group: this.allGroupLabel } ] | ||
71 | } | ||
72 | } | ||
73 | |||
74 | private isMaxConstraintValid () { | ||
75 | if (!this.maxItems) return true | ||
76 | |||
77 | const outputItems = this.buildOutputItems() | ||
78 | if (!outputItems) return true | ||
79 | |||
80 | if (outputItems.length >= this.maxItems) { | ||
81 | this.notifier.error($localize`You can't select more than ${this.maxItems} items`) | ||
82 | |||
83 | return false | ||
84 | } | ||
85 | |||
86 | return true | ||
87 | } | ||
88 | |||
89 | private buildOutputItems () { | ||
90 | if (!Array.isArray(this.selectedItems)) return undefined | ||
91 | |||
92 | // null means "All" | ||
93 | if (this.selectedItems.length === 0 || this.selectedItems.length === this.availableItems.length) { | ||
94 | return null | ||
95 | } | ||
96 | |||
97 | if (this.selectedItems.length === 1) { | ||
98 | const item = this.selectedItems[0] | ||
99 | |||
100 | const itemGroup = typeof item === 'string' || typeof item === 'number' | ||
101 | ? item | ||
102 | : item.group | ||
103 | |||
104 | if (itemGroup === this.allGroupLabel) return null | ||
105 | } | ||
106 | |||
107 | return this.selectedItems.map(l => { | ||
108 | if (typeof l === 'string' || typeof l === 'number') return l | ||
109 | |||
110 | if (l.group) return l.group | ||
111 | |||
112 | return l.id + '' | ||
113 | }) | ||
114 | } | ||
115 | } | ||
diff --git a/client/src/app/shared/shared-forms/select/select-checkbox.component.html b/client/src/app/shared/shared-forms/select/select-checkbox.component.html index f5af2932e..7b49a0c01 100644 --- a/client/src/app/shared/shared-forms/select/select-checkbox.component.html +++ b/client/src/app/shared/shared-forms/select/select-checkbox.component.html | |||
@@ -18,8 +18,6 @@ | |||
18 | 18 | ||
19 | groupBy="group" | 19 | groupBy="group" |
20 | [compareWith]="compareFn" | 20 | [compareWith]="compareFn" |
21 | |||
22 | [maxSelectedItems]="maxSelectedItems" | ||
23 | > | 21 | > |
24 | 22 | ||
25 | <ng-template ng-optgroup-tmp let-item="item" let-item$="item$" let-index="index"> | 23 | <ng-template ng-optgroup-tmp let-item="item" let-item$="item$" let-index="index"> |
diff --git a/client/src/app/shared/shared-forms/select/select-checkbox.component.ts b/client/src/app/shared/shared-forms/select/select-checkbox.component.ts index c2523f15c..12f697628 100644 --- a/client/src/app/shared/shared-forms/select/select-checkbox.component.ts +++ b/client/src/app/shared/shared-forms/select/select-checkbox.component.ts | |||
@@ -2,7 +2,7 @@ import { Component, forwardRef, Input, OnInit } from '@angular/core' | |||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | 2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' |
3 | import { SelectOptionsItem } from '../../../../types/select-options-item.model' | 3 | import { SelectOptionsItem } from '../../../../types/select-options-item.model' |
4 | 4 | ||
5 | export type ItemSelectCheckboxValue = { id?: string | number, group?: string } | string | 5 | export type ItemSelectCheckboxValue = { id?: string, group?: string } | string |
6 | 6 | ||
7 | @Component({ | 7 | @Component({ |
8 | selector: 'my-select-checkbox', | 8 | selector: 'my-select-checkbox', |
@@ -21,7 +21,6 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor { | |||
21 | @Input() selectedItems: ItemSelectCheckboxValue[] = [] | 21 | @Input() selectedItems: ItemSelectCheckboxValue[] = [] |
22 | @Input() selectableGroup: boolean | 22 | @Input() selectableGroup: boolean |
23 | @Input() selectableGroupAsModel: boolean | 23 | @Input() selectableGroupAsModel: boolean |
24 | @Input() maxSelectedItems: number | ||
25 | @Input() placeholder: string | 24 | @Input() placeholder: string |
26 | 25 | ||
27 | ngOnInit () { | 26 | ngOnInit () { |
@@ -46,8 +45,6 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor { | |||
46 | } else { | 45 | } else { |
47 | this.selectedItems = items | 46 | this.selectedItems = items |
48 | } | 47 | } |
49 | |||
50 | this.propagateChange(this.selectedItems) | ||
51 | } | 48 | } |
52 | 49 | ||
53 | registerOnChange (fn: (_: any) => void) { | 50 | registerOnChange (fn: (_: any) => void) { |
@@ -63,7 +60,7 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor { | |||
63 | } | 60 | } |
64 | 61 | ||
65 | compareFn (item: SelectOptionsItem, selected: ItemSelectCheckboxValue) { | 62 | compareFn (item: SelectOptionsItem, selected: ItemSelectCheckboxValue) { |
66 | if (typeof selected === 'string') { | 63 | if (typeof selected === 'string' || typeof selected === 'number') { |
67 | return item.id === selected | 64 | return item.id === selected |
68 | } | 65 | } |
69 | 66 | ||
diff --git a/client/src/app/shared/shared-forms/select/select-languages.component.html b/client/src/app/shared/shared-forms/select/select-languages.component.html new file mode 100644 index 000000000..6eba26a56 --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-languages.component.html | |||
@@ -0,0 +1,9 @@ | |||
1 | <my-select-checkbox-all | ||
2 | [(ngModel)]="selectedLanguages" | ||
3 | (ngModelChange)="onModelChange()" | ||
4 | [availableItems]="availableLanguages" | ||
5 | [maxItems]="maxLanguages" | ||
6 | i18n-placeholder placeholder="Add a new language" | ||
7 | [allGroupLabel]="allLanguagesGroup" | ||
8 | > | ||
9 | </my-select-checkbox-all> | ||
diff --git a/client/src/app/shared/shared-forms/select/select-languages.component.ts b/client/src/app/shared/shared-forms/select/select-languages.component.ts new file mode 100644 index 000000000..742163ede --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-languages.component.ts | |||
@@ -0,0 +1,74 @@ | |||
1 | import { Component, forwardRef, Input, OnInit } from '@angular/core' | ||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
3 | import { ServerService } from '@app/core' | ||
4 | import { SelectOptionsItem } from '../../../../types/select-options-item.model' | ||
5 | import { ItemSelectCheckboxValue } from './select-checkbox.component' | ||
6 | |||
7 | @Component({ | ||
8 | selector: 'my-select-languages', | ||
9 | styleUrls: [ './select-shared.component.scss' ], | ||
10 | templateUrl: './select-languages.component.html', | ||
11 | providers: [ | ||
12 | { | ||
13 | provide: NG_VALUE_ACCESSOR, | ||
14 | useExisting: forwardRef(() => SelectLanguagesComponent), | ||
15 | multi: true | ||
16 | } | ||
17 | ] | ||
18 | }) | ||
19 | export class SelectLanguagesComponent implements ControlValueAccessor, OnInit { | ||
20 | @Input() maxLanguages: number | ||
21 | |||
22 | selectedLanguages: ItemSelectCheckboxValue[] | ||
23 | availableLanguages: SelectOptionsItem[] = [] | ||
24 | |||
25 | allLanguagesGroup = $localize`All languages` | ||
26 | |||
27 | // Fix a bug on ng-select when we update items after we selected items | ||
28 | private toWrite: any | ||
29 | private loaded = false | ||
30 | |||
31 | constructor ( | ||
32 | private server: ServerService | ||
33 | ) { | ||
34 | |||
35 | } | ||
36 | |||
37 | ngOnInit () { | ||
38 | this.server.getVideoLanguages() | ||
39 | .subscribe( | ||
40 | languages => { | ||
41 | this.availableLanguages = [ { label: $localize`Unknown language`, id: '_unknown', group: this.allLanguagesGroup } ] | ||
42 | |||
43 | this.availableLanguages = this.availableLanguages | ||
44 | .concat(languages.map(l => ({ label: l.label, id: l.id, group: this.allLanguagesGroup }))) | ||
45 | |||
46 | this.loaded = true | ||
47 | this.writeValue(this.toWrite) | ||
48 | } | ||
49 | ) | ||
50 | } | ||
51 | |||
52 | propagateChange = (_: any) => { /* empty */ } | ||
53 | |||
54 | writeValue (languages: ItemSelectCheckboxValue[]) { | ||
55 | if (!this.loaded) { | ||
56 | this.toWrite = languages | ||
57 | return | ||
58 | } | ||
59 | |||
60 | this.selectedLanguages = languages | ||
61 | } | ||
62 | |||
63 | registerOnChange (fn: (_: any) => void) { | ||
64 | this.propagateChange = fn | ||
65 | } | ||
66 | |||
67 | registerOnTouched () { | ||
68 | // Unused | ||
69 | } | ||
70 | |||
71 | onModelChange () { | ||
72 | this.propagateChange(this.selectedLanguages) | ||
73 | } | ||
74 | } | ||
diff --git a/client/src/app/shared/shared-forms/shared-form.module.ts b/client/src/app/shared/shared-forms/shared-form.module.ts index 5417f7342..60c2f66ae 100644 --- a/client/src/app/shared/shared-forms/shared-form.module.ts +++ b/client/src/app/shared/shared-forms/shared-form.module.ts | |||
@@ -15,9 +15,12 @@ import { PeertubeCheckboxComponent } from './peertube-checkbox.component' | |||
15 | import { PreviewUploadComponent } from './preview-upload.component' | 15 | import { PreviewUploadComponent } from './preview-upload.component' |
16 | import { ReactiveFileComponent } from './reactive-file.component' | 16 | import { ReactiveFileComponent } from './reactive-file.component' |
17 | import { | 17 | import { |
18 | SelectCategoriesComponent, | ||
18 | SelectChannelComponent, | 19 | SelectChannelComponent, |
20 | SelectCheckboxAllComponent, | ||
19 | SelectCheckboxComponent, | 21 | SelectCheckboxComponent, |
20 | SelectCustomValueComponent, | 22 | SelectCustomValueComponent, |
23 | SelectLanguagesComponent, | ||
21 | SelectOptionsComponent, | 24 | SelectOptionsComponent, |
22 | SelectTagsComponent | 25 | SelectTagsComponent |
23 | } from './select' | 26 | } from './select' |
@@ -52,6 +55,9 @@ import { TimestampInputComponent } from './timestamp-input.component' | |||
52 | SelectTagsComponent, | 55 | SelectTagsComponent, |
53 | SelectCheckboxComponent, | 56 | SelectCheckboxComponent, |
54 | SelectCustomValueComponent, | 57 | SelectCustomValueComponent, |
58 | SelectLanguagesComponent, | ||
59 | SelectCategoriesComponent, | ||
60 | SelectCheckboxAllComponent, | ||
55 | 61 | ||
56 | DynamicFormFieldComponent, | 62 | DynamicFormFieldComponent, |
57 | 63 | ||
@@ -80,6 +86,9 @@ import { TimestampInputComponent } from './timestamp-input.component' | |||
80 | SelectTagsComponent, | 86 | SelectTagsComponent, |
81 | SelectCheckboxComponent, | 87 | SelectCheckboxComponent, |
82 | SelectCustomValueComponent, | 88 | SelectCustomValueComponent, |
89 | SelectLanguagesComponent, | ||
90 | SelectCategoriesComponent, | ||
91 | SelectCheckboxAllComponent, | ||
83 | 92 | ||
84 | DynamicFormFieldComponent, | 93 | DynamicFormFieldComponent, |
85 | 94 | ||
diff --git a/client/src/app/shared/shared-icons/global-icon.component.ts b/client/src/app/shared/shared-icons/global-icon.component.ts index cb5f31c8e..70d672306 100644 --- a/client/src/app/shared/shared-icons/global-icon.component.ts +++ b/client/src/app/shared/shared-icons/global-icon.component.ts | |||
@@ -71,6 +71,7 @@ const icons = { | |||
71 | columns: require('!!raw-loader?!../../../assets/images/feather/columns.svg').default, | 71 | columns: require('!!raw-loader?!../../../assets/images/feather/columns.svg').default, |
72 | live: require('!!raw-loader?!../../../assets/images/feather/live.svg').default, | 72 | live: require('!!raw-loader?!../../../assets/images/feather/live.svg').default, |
73 | repeat: require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default, | 73 | repeat: require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default, |
74 | 'chevrons-up': require('!!raw-loader?!../../../assets/images/feather/chevrons-up.svg').default, | ||
74 | 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, | 75 | 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, |
75 | codesandbox: require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default, | 76 | codesandbox: require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default, |
76 | award: require('!!raw-loader?!../../../assets/images/feather/award.svg').default | 77 | award: require('!!raw-loader?!../../../assets/images/feather/award.svg').default |
diff --git a/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts b/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts index dc212788a..bebc6efa7 100644 --- a/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts +++ b/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts | |||
@@ -1,16 +1,19 @@ | |||
1 | import { fromEvent, Observable, Subscription } from 'rxjs' | 1 | import { fromEvent, Observable, Subscription } from 'rxjs' |
2 | import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' | 2 | import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' |
3 | import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' | 3 | import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' |
4 | import { PeerTubeRouterService, RouterSetting } from '@app/core' | ||
4 | 5 | ||
5 | @Directive({ | 6 | @Directive({ |
6 | selector: '[myInfiniteScroller]' | 7 | selector: '[myInfiniteScroller]' |
7 | }) | 8 | }) |
8 | export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked { | 9 | export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked { |
9 | @Input() percentLimit = 70 | 10 | @Input() percentLimit = 70 |
10 | @Input() autoInit = false | ||
11 | @Input() onItself = false | 11 | @Input() onItself = false |
12 | @Input() dataObservable: Observable<any[]> | 12 | @Input() dataObservable: Observable<any[]> |
13 | 13 | ||
14 | // Add angular state in query params to reuse the routed component | ||
15 | @Input() setAngularState: boolean | ||
16 | |||
14 | @Output() nearOfBottom = new EventEmitter<void>() | 17 | @Output() nearOfBottom = new EventEmitter<void>() |
15 | 18 | ||
16 | private decimalLimit = 0 | 19 | private decimalLimit = 0 |
@@ -20,7 +23,10 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh | |||
20 | 23 | ||
21 | private checkScroll = false | 24 | private checkScroll = false |
22 | 25 | ||
23 | constructor (private el: ElementRef) { | 26 | constructor ( |
27 | private peertubeRouter: PeerTubeRouterService, | ||
28 | private el: ElementRef | ||
29 | ) { | ||
24 | this.decimalLimit = this.percentLimit / 100 | 30 | this.decimalLimit = this.percentLimit / 100 |
25 | } | 31 | } |
26 | 32 | ||
@@ -36,7 +42,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh | |||
36 | } | 42 | } |
37 | 43 | ||
38 | ngOnInit () { | 44 | ngOnInit () { |
39 | if (this.autoInit === true) return this.initialize() | 45 | this.initialize() |
40 | } | 46 | } |
41 | 47 | ||
42 | ngOnDestroy () { | 48 | ngOnDestroy () { |
@@ -67,7 +73,11 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh | |||
67 | filter(({ current }) => this.isScrollingDown(current)), | 73 | filter(({ current }) => this.isScrollingDown(current)), |
68 | filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit) | 74 | filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit) |
69 | ) | 75 | ) |
70 | .subscribe(() => this.nearOfBottom.emit()) | 76 | .subscribe(() => { |
77 | if (this.setAngularState) this.setScrollRouteParams() | ||
78 | |||
79 | this.nearOfBottom.emit() | ||
80 | }) | ||
71 | 81 | ||
72 | if (this.dataObservable) { | 82 | if (this.dataObservable) { |
73 | this.dataObservable | 83 | this.dataObservable |
@@ -96,4 +106,8 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh | |||
96 | this.lastCurrentBottom = current | 106 | this.lastCurrentBottom = current |
97 | return result | 107 | return result |
98 | } | 108 | } |
109 | |||
110 | private setScrollRouteParams () { | ||
111 | this.peertubeRouter.addRouteSetting(RouterSetting.REUSE_COMPONENT) | ||
112 | } | ||
99 | } | 113 | } |
diff --git a/client/src/app/shared/shared-main/feeds/feed.component.scss b/client/src/app/shared/shared-main/feeds/feed.component.scss index a1838c485..bf1f4eeeb 100644 --- a/client/src/app/shared/shared-main/feeds/feed.component.scss +++ b/client/src/app/shared/shared-main/feeds/feed.component.scss | |||
@@ -7,12 +7,11 @@ | |||
7 | a { | 7 | a { |
8 | color: #000; | 8 | color: #000; |
9 | display: block; | 9 | display: block; |
10 | min-width: 100px; | ||
10 | } | 11 | } |
11 | } | 12 | } |
12 | 13 | ||
13 | my-global-icon { | 14 | my-global-icon { |
14 | @include apply-svg-color(pvar(--mainForegroundColor)); | ||
15 | |||
16 | cursor: pointer; | 15 | cursor: pointer; |
17 | width: 100%; | 16 | width: 100%; |
18 | } | 17 | } |
diff --git a/client/src/app/shared/shared-main/misc/simple-search-input.component.html b/client/src/app/shared/shared-main/misc/simple-search-input.component.html index c20c02e23..1e2f6c6a9 100644 --- a/client/src/app/shared/shared-main/misc/simple-search-input.component.html +++ b/client/src/app/shared/shared-main/misc/simple-search-input.component.html | |||
@@ -1,13 +1,18 @@ | |||
1 | <div class="root"> | 1 | <div class="root"> |
2 | <input | 2 | <div class="input-group has-feedback has-clear"> |
3 | #ref | 3 | <input |
4 | type="text" | 4 | #ref |
5 | [(ngModel)]="value" | 5 | type="text" |
6 | (keyup.enter)="searchChange()" | 6 | [(ngModel)]="value" |
7 | [hidden]="!inputShown" | 7 | (keyup.enter)="sendSearch()" |
8 | [name]="name" | 8 | [hidden]="!inputShown" |
9 | [placeholder]="placeholder" | 9 | [name]="name" |
10 | > | 10 | [placeholder]="placeholder" |
11 | > | ||
12 | |||
13 | <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="onResetFilter()"></a> | ||
14 | <span class="sr-only" i18n>Clear filters</span> | ||
15 | </div> | ||
11 | 16 | ||
12 | <my-global-icon iconName="search" aria-label="Search" role="button" (click)="onIconClick()" [title]="iconTitle"></my-global-icon> | 17 | <my-global-icon iconName="search" aria-label="Search" role="button" (click)="onIconClick()" [title]="iconTitle"></my-global-icon> |
13 | 18 | ||
diff --git a/client/src/app/shared/shared-main/misc/simple-search-input.component.scss b/client/src/app/shared/shared-main/misc/simple-search-input.component.scss index 173204291..d5fcff760 100644 --- a/client/src/app/shared/shared-main/misc/simple-search-input.component.scss +++ b/client/src/app/shared/shared-main/misc/simple-search-input.component.scss | |||
@@ -11,20 +11,17 @@ my-global-icon { | |||
11 | height: 28px; | 11 | height: 28px; |
12 | width: 28px; | 12 | width: 28px; |
13 | cursor: pointer; | 13 | cursor: pointer; |
14 | color: pvar(--mainColor); | ||
14 | 15 | ||
15 | &:hover { | 16 | &:hover { |
16 | color: pvar(--mainHoverColor); | 17 | color: pvar(--mainHoverColor); |
17 | } | 18 | } |
18 | |||
19 | &[iconName=search] { | ||
20 | color: pvar(--mainForegroundColor); | ||
21 | } | ||
22 | |||
23 | &[iconName=cross] { | ||
24 | color: pvar(--mainForegroundColor); | ||
25 | } | ||
26 | } | 19 | } |
27 | 20 | ||
28 | input { | 21 | input { |
29 | @include peertube-input-text(200px); | 22 | @include peertube-input-text(200px); |
23 | |||
24 | &:focus { | ||
25 | box-shadow: 0 0 5px 0 #a5a5a5; | ||
26 | } | ||
30 | } | 27 | } |
diff --git a/client/src/app/shared/shared-main/misc/simple-search-input.component.ts b/client/src/app/shared/shared-main/misc/simple-search-input.component.ts index 292ec4c82..99abb94e7 100644 --- a/client/src/app/shared/shared-main/misc/simple-search-input.component.ts +++ b/client/src/app/shared/shared-main/misc/simple-search-input.component.ts | |||
@@ -1,7 +1,4 @@ | |||
1 | import { Subject } from 'rxjs' | ||
2 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' | ||
3 | import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | 1 | import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' |
4 | import { ActivatedRoute, Router } from '@angular/router' | ||
5 | 2 | ||
6 | @Component({ | 3 | @Component({ |
7 | selector: 'my-simple-search-input', | 4 | selector: 'my-simple-search-input', |
@@ -22,23 +19,9 @@ export class SimpleSearchInputComponent implements OnInit { | |||
22 | value = '' | 19 | value = '' |
23 | inputShown: boolean | 20 | inputShown: boolean |
24 | 21 | ||
25 | private searchSubject = new Subject<string>() | 22 | private hasAlreadySentSearch = false |
26 | |||
27 | constructor ( | ||
28 | private router: Router, | ||
29 | private route: ActivatedRoute | ||
30 | ) {} | ||
31 | 23 | ||
32 | ngOnInit () { | 24 | ngOnInit () { |
33 | this.searchSubject | ||
34 | .pipe( | ||
35 | debounceTime(400), | ||
36 | distinctUntilChanged() | ||
37 | ) | ||
38 | .subscribe(value => this.searchChanged.emit(value)) | ||
39 | |||
40 | this.searchSubject.next(this.value) | ||
41 | |||
42 | if (this.isInputShown()) this.showInput(false) | 25 | if (this.isInputShown()) this.showInput(false) |
43 | } | 26 | } |
44 | 27 | ||
@@ -54,7 +37,7 @@ export class SimpleSearchInputComponent implements OnInit { | |||
54 | return | 37 | return |
55 | } | 38 | } |
56 | 39 | ||
57 | this.searchChange() | 40 | this.sendSearch() |
58 | } | 41 | } |
59 | 42 | ||
60 | showInput (focus = true) { | 43 | showInput (focus = true) { |
@@ -80,9 +63,14 @@ export class SimpleSearchInputComponent implements OnInit { | |||
80 | this.hideInput() | 63 | this.hideInput() |
81 | } | 64 | } |
82 | 65 | ||
83 | searchChange () { | 66 | sendSearch () { |
84 | this.router.navigate([ './search' ], { relativeTo: this.route }) | 67 | this.hasAlreadySentSearch = true |
68 | this.searchChanged.emit(this.value) | ||
69 | } | ||
70 | |||
71 | onResetFilter () { | ||
72 | this.value = '' | ||
85 | 73 | ||
86 | this.searchSubject.next(this.value) | 74 | if (this.hasAlreadySentSearch) this.sendSearch() |
87 | } | 75 | } |
88 | } | 76 | } |
diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.html b/client/src/app/shared/shared-main/users/user-notifications.component.html index 325f0eaae..ee8df864a 100644 --- a/client/src/app/shared/shared-main/users/user-notifications.component.html +++ b/client/src/app/shared/shared-main/users/user-notifications.component.html | |||
@@ -1,6 +1,6 @@ | |||
1 | <div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div> | 1 | <div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div> |
2 | 2 | ||
3 | <div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> | 3 | <div class="notifications" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> |
4 | <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)"> | 4 | <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)"> |
5 | 5 | ||
6 | <ng-container [ngSwitch]="notification.type"> | 6 | <ng-container [ngSwitch]="notification.type"> |
diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 60cc9d160..3481b116f 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts | |||
@@ -5,6 +5,7 @@ import { Injectable } from '@angular/core' | |||
5 | import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' | 5 | import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' |
6 | import { objectToFormData } from '@app/helpers' | 6 | import { objectToFormData } from '@app/helpers' |
7 | import { | 7 | import { |
8 | BooleanBothQuery, | ||
8 | FeedFormat, | 9 | FeedFormat, |
9 | NSFWPolicyType, | 10 | NSFWPolicyType, |
10 | ResultList, | 11 | ResultList, |
@@ -28,19 +29,21 @@ import { VideoDetails } from './video-details.model' | |||
28 | import { VideoEdit } from './video-edit.model' | 29 | import { VideoEdit } from './video-edit.model' |
29 | import { Video } from './video.model' | 30 | import { Video } from './video.model' |
30 | 31 | ||
31 | export interface VideosProvider { | 32 | export type CommonVideoParams = { |
32 | getVideos (parameters: { | 33 | videoPagination: ComponentPaginationLight |
33 | videoPagination: ComponentPaginationLight | 34 | sort: VideoSortField |
34 | sort: VideoSortField | 35 | filter?: VideoFilter |
35 | filter?: VideoFilter | 36 | categoryOneOf?: number[] |
36 | categoryOneOf?: number[] | 37 | languageOneOf?: string[] |
37 | languageOneOf?: string[] | 38 | isLive?: boolean |
38 | nsfwPolicy: NSFWPolicyType | 39 | skipCount?: boolean |
39 | }): Observable<ResultList<Video>> | 40 | // FIXME: remove? |
41 | nsfwPolicy?: NSFWPolicyType | ||
42 | nsfw?: BooleanBothQuery | ||
40 | } | 43 | } |
41 | 44 | ||
42 | @Injectable() | 45 | @Injectable() |
43 | export class VideoService implements VideosProvider { | 46 | export class VideoService { |
44 | static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' | 47 | static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' |
45 | static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' | 48 | static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' |
46 | static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.' | 49 | static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.' |
@@ -144,32 +147,16 @@ export class VideoService implements VideosProvider { | |||
144 | ) | 147 | ) |
145 | } | 148 | } |
146 | 149 | ||
147 | getAccountVideos (parameters: { | 150 | getAccountVideos (parameters: CommonVideoParams & { |
148 | account: Pick<Account, 'nameWithHost'> | 151 | account: Pick<Account, 'nameWithHost'> |
149 | videoPagination: ComponentPaginationLight | ||
150 | sort: VideoSortField | ||
151 | nsfwPolicy?: NSFWPolicyType | ||
152 | videoFilter?: VideoFilter | ||
153 | search?: string | 152 | search?: string |
154 | }): Observable<ResultList<Video>> { | 153 | }): Observable<ResultList<Video>> { |
155 | const { account, videoPagination, sort, videoFilter, nsfwPolicy, search } = parameters | 154 | const { account, search } = parameters |
156 | |||
157 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | ||
158 | 155 | ||
159 | let params = new HttpParams() | 156 | let params = new HttpParams() |
160 | params = this.restService.addRestGetParams(params, pagination, sort) | 157 | params = this.buildCommonVideosParams({ params, ...parameters }) |
161 | |||
162 | if (nsfwPolicy) { | ||
163 | params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) | ||
164 | } | ||
165 | |||
166 | if (videoFilter) { | ||
167 | params = params.set('filter', videoFilter) | ||
168 | } | ||
169 | 158 | ||
170 | if (search) { | 159 | if (search) params = params.set('search', search) |
171 | params = params.set('search', search) | ||
172 | } | ||
173 | 160 | ||
174 | return this.authHttp | 161 | return this.authHttp |
175 | .get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params }) | 162 | .get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params }) |
@@ -179,27 +166,13 @@ export class VideoService implements VideosProvider { | |||
179 | ) | 166 | ) |
180 | } | 167 | } |
181 | 168 | ||
182 | getVideoChannelVideos (parameters: { | 169 | getVideoChannelVideos (parameters: CommonVideoParams & { |
183 | videoChannel: Pick<VideoChannel, 'nameWithHost'> | 170 | videoChannel: Pick<VideoChannel, 'nameWithHost'> |
184 | videoPagination: ComponentPaginationLight | ||
185 | sort: VideoSortField | ||
186 | nsfwPolicy?: NSFWPolicyType | ||
187 | videoFilter?: VideoFilter | ||
188 | }): Observable<ResultList<Video>> { | 171 | }): Observable<ResultList<Video>> { |
189 | const { videoChannel, videoPagination, sort, nsfwPolicy, videoFilter } = parameters | 172 | const { videoChannel } = parameters |
190 | |||
191 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | ||
192 | 173 | ||
193 | let params = new HttpParams() | 174 | let params = new HttpParams() |
194 | params = this.restService.addRestGetParams(params, pagination, sort) | 175 | params = this.buildCommonVideosParams({ params, ...parameters }) |
195 | |||
196 | if (nsfwPolicy) { | ||
197 | params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) | ||
198 | } | ||
199 | |||
200 | if (videoFilter) { | ||
201 | params = params.set('filter', videoFilter) | ||
202 | } | ||
203 | 176 | ||
204 | return this.authHttp | 177 | return this.authHttp |
205 | .get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params }) | 178 | .get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params }) |
@@ -209,30 +182,9 @@ export class VideoService implements VideosProvider { | |||
209 | ) | 182 | ) |
210 | } | 183 | } |
211 | 184 | ||
212 | getVideos (parameters: { | 185 | getVideos (parameters: CommonVideoParams): Observable<ResultList<Video>> { |
213 | videoPagination: ComponentPaginationLight | ||
214 | sort: VideoSortField | ||
215 | filter?: VideoFilter | ||
216 | categoryOneOf?: number[] | ||
217 | languageOneOf?: string[] | ||
218 | isLive?: boolean | ||
219 | skipCount?: boolean | ||
220 | nsfwPolicy?: NSFWPolicyType | ||
221 | }): Observable<ResultList<Video>> { | ||
222 | const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive } = parameters | ||
223 | |||
224 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | ||
225 | |||
226 | let params = new HttpParams() | 186 | let params = new HttpParams() |
227 | params = this.restService.addRestGetParams(params, pagination, sort) | 187 | params = this.buildCommonVideosParams({ params, ...parameters }) |
228 | |||
229 | if (filter) params = params.set('filter', filter) | ||
230 | if (skipCount) params = params.set('skipCount', skipCount + '') | ||
231 | |||
232 | if (isLive) params = params.set('isLive', isLive) | ||
233 | if (nsfwPolicy) params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) | ||
234 | if (languageOneOf) this.restService.addArrayParams(params, 'languageOneOf', languageOneOf) | ||
235 | if (categoryOneOf) this.restService.addArrayParams(params, 'categoryOneOf', categoryOneOf) | ||
236 | 188 | ||
237 | return this.authHttp | 189 | return this.authHttp |
238 | .get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params }) | 190 | .get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params }) |
@@ -421,4 +373,22 @@ export class VideoService implements VideosProvider { | |||
421 | catchError(err => this.restExtractor.handleError(err)) | 373 | catchError(err => this.restExtractor.handleError(err)) |
422 | ) | 374 | ) |
423 | } | 375 | } |
376 | |||
377 | private buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) { | ||
378 | const { params, videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive, nsfw } = options | ||
379 | |||
380 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | ||
381 | let newParams = this.restService.addRestGetParams(params, pagination, sort) | ||
382 | |||
383 | if (filter) newParams = newParams.set('filter', filter) | ||
384 | if (skipCount) newParams = newParams.set('skipCount', skipCount + '') | ||
385 | |||
386 | if (isLive) newParams = newParams.set('isLive', isLive) | ||
387 | if (nsfw) newParams = newParams.set('nsfw', nsfw) | ||
388 | if (nsfwPolicy) newParams = newParams.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) | ||
389 | if (languageOneOf) newParams = this.restService.addArrayParams(newParams, 'languageOneOf', languageOneOf) | ||
390 | if (categoryOneOf) newParams = this.restService.addArrayParams(newParams, 'categoryOneOf', categoryOneOf) | ||
391 | |||
392 | return newParams | ||
393 | } | ||
424 | } | 394 | } |
diff --git a/client/src/app/shared/shared-search/advanced-search.model.ts b/client/src/app/shared/shared-search/advanced-search.model.ts index 9c55f6cd8..2675c6135 100644 --- a/client/src/app/shared/shared-search/advanced-search.model.ts +++ b/client/src/app/shared/shared-search/advanced-search.model.ts | |||
@@ -1,3 +1,4 @@ | |||
1 | import { intoArray } from '@app/helpers' | ||
1 | import { | 2 | import { |
2 | BooleanBothQuery, | 3 | BooleanBothQuery, |
3 | BooleanQuery, | 4 | BooleanQuery, |
@@ -74,8 +75,8 @@ export class AdvancedSearch { | |||
74 | this.categoryOneOf = options.categoryOneOf || undefined | 75 | this.categoryOneOf = options.categoryOneOf || undefined |
75 | this.licenceOneOf = options.licenceOneOf || undefined | 76 | this.licenceOneOf = options.licenceOneOf || undefined |
76 | this.languageOneOf = options.languageOneOf || undefined | 77 | this.languageOneOf = options.languageOneOf || undefined |
77 | this.tagsOneOf = this.intoArray(options.tagsOneOf) | 78 | this.tagsOneOf = intoArray(options.tagsOneOf) |
78 | this.tagsAllOf = this.intoArray(options.tagsAllOf) | 79 | this.tagsAllOf = intoArray(options.tagsAllOf) |
79 | this.durationMin = parseInt(options.durationMin, 10) | 80 | this.durationMin = parseInt(options.durationMin, 10) |
80 | this.durationMax = parseInt(options.durationMax, 10) | 81 | this.durationMax = parseInt(options.durationMax, 10) |
81 | 82 | ||
@@ -150,9 +151,9 @@ export class AdvancedSearch { | |||
150 | originallyPublishedStartDate: this.originallyPublishedStartDate, | 151 | originallyPublishedStartDate: this.originallyPublishedStartDate, |
151 | originallyPublishedEndDate: this.originallyPublishedEndDate, | 152 | originallyPublishedEndDate: this.originallyPublishedEndDate, |
152 | nsfw: this.nsfw, | 153 | nsfw: this.nsfw, |
153 | categoryOneOf: this.intoArray(this.categoryOneOf), | 154 | categoryOneOf: intoArray(this.categoryOneOf), |
154 | licenceOneOf: this.intoArray(this.licenceOneOf), | 155 | licenceOneOf: intoArray(this.licenceOneOf), |
155 | languageOneOf: this.intoArray(this.languageOneOf), | 156 | languageOneOf: intoArray(this.languageOneOf), |
156 | tagsOneOf: this.tagsOneOf, | 157 | tagsOneOf: this.tagsOneOf, |
157 | tagsAllOf: this.tagsAllOf, | 158 | tagsAllOf: this.tagsAllOf, |
158 | durationMin: this.durationMin, | 159 | durationMin: this.durationMin, |
@@ -198,13 +199,4 @@ export class AdvancedSearch { | |||
198 | 199 | ||
199 | return true | 200 | return true |
200 | } | 201 | } |
201 | |||
202 | private intoArray (value: any) { | ||
203 | if (!value) return undefined | ||
204 | if (Array.isArray(value)) return value | ||
205 | |||
206 | if (typeof value === 'string') return value.split(',') | ||
207 | |||
208 | return [ value ] | ||
209 | } | ||
210 | } | 202 | } |
diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.html b/client/src/app/shared/shared-user-settings/user-video-settings.component.html index a49e11485..bc9dd0f7f 100644 --- a/client/src/app/shared/shared-user-settings/user-video-settings.component.html +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.html | |||
@@ -30,12 +30,7 @@ | |||
30 | </my-help> | 30 | </my-help> |
31 | 31 | ||
32 | <div> | 32 | <div> |
33 | <my-select-checkbox | 33 | <my-select-languages formControlName="videoLanguages"></my-select-languages> |
34 | formControlName="videoLanguages" [availableItems]="languageItems" | ||
35 | [selectableGroup]="true" [selectableGroupAsModel]="true" | ||
36 | i18n-placeholder placeholder="Add a new language" | ||
37 | > | ||
38 | </my-select-checkbox > | ||
39 | </div> | 34 | </div> |
40 | </div> | 35 | </div> |
41 | 36 | ||
diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.scss b/client/src/app/shared/shared-user-settings/user-video-settings.component.scss index 4b007b691..c4f6020d4 100644 --- a/client/src/app/shared/shared-user-settings/user-video-settings.component.scss +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.scss | |||
@@ -19,7 +19,7 @@ input[type=submit] { | |||
19 | margin-bottom: 30px; | 19 | margin-bottom: 30px; |
20 | } | 20 | } |
21 | 21 | ||
22 | my-select-checkbox { | 22 | my-select-languages { |
23 | @include responsive-width(340px); | 23 | @include responsive-width(340px); |
24 | 24 | ||
25 | display: block; | 25 | display: block; |
diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.ts b/client/src/app/shared/shared-user-settings/user-video-settings.component.ts index 5d6e11c04..0cd889a8a 100644 --- a/client/src/app/shared/shared-user-settings/user-video-settings.component.ts +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.ts | |||
@@ -1,12 +1,11 @@ | |||
1 | import { pick } from 'lodash-es' | 1 | import { pick } from 'lodash-es' |
2 | import { forkJoin, Subject, Subscription } from 'rxjs' | 2 | import { Subject, Subscription } from 'rxjs' |
3 | import { first } from 'rxjs/operators' | 3 | import { first } from 'rxjs/operators' |
4 | import { Component, Input, OnDestroy, OnInit } from '@angular/core' | 4 | import { Component, Input, OnDestroy, OnInit } from '@angular/core' |
5 | import { AuthService, Notifier, ServerService, User, UserService } from '@app/core' | 5 | import { AuthService, Notifier, ServerService, User, UserService } from '@app/core' |
6 | import { FormReactive, FormValidatorService, ItemSelectCheckboxValue } from '@app/shared/shared-forms' | 6 | import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' |
7 | import { UserUpdateMe } from '@shared/models' | 7 | import { UserUpdateMe } from '@shared/models' |
8 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' | 8 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' |
9 | import { SelectOptionsItem } from '../../../types/select-options-item.model' | ||
10 | 9 | ||
11 | @Component({ | 10 | @Component({ |
12 | selector: 'my-user-video-settings', | 11 | selector: 'my-user-video-settings', |
@@ -19,12 +18,9 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, | |||
19 | @Input() notifyOnUpdate = true | 18 | @Input() notifyOnUpdate = true |
20 | @Input() userInformationLoaded: Subject<any> | 19 | @Input() userInformationLoaded: Subject<any> |
21 | 20 | ||
22 | languageItems: SelectOptionsItem[] = [] | ||
23 | defaultNSFWPolicy: NSFWPolicyType | 21 | defaultNSFWPolicy: NSFWPolicyType |
24 | formValuesWatcher: Subscription | 22 | formValuesWatcher: Subscription |
25 | 23 | ||
26 | private allLanguagesGroup: string | ||
27 | |||
28 | constructor ( | 24 | constructor ( |
29 | protected formValidatorService: FormValidatorService, | 25 | protected formValidatorService: FormValidatorService, |
30 | private authService: AuthService, | 26 | private authService: AuthService, |
@@ -36,8 +32,6 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, | |||
36 | } | 32 | } |
37 | 33 | ||
38 | ngOnInit () { | 34 | ngOnInit () { |
39 | this.allLanguagesGroup = $localize`All languages` | ||
40 | |||
41 | this.buildForm({ | 35 | this.buildForm({ |
42 | nsfwPolicy: null, | 36 | nsfwPolicy: null, |
43 | webTorrentEnabled: null, | 37 | webTorrentEnabled: null, |
@@ -46,33 +40,23 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, | |||
46 | videoLanguages: null | 40 | videoLanguages: null |
47 | }) | 41 | }) |
48 | 42 | ||
49 | forkJoin([ | 43 | this.userInformationLoaded.pipe(first()) |
50 | this.serverService.getVideoLanguages(), | 44 | .subscribe( |
51 | this.userInformationLoaded.pipe(first()) | 45 | () => { |
52 | ]).subscribe(([ languages ]) => { | 46 | const serverConfig = this.serverService.getHTMLConfig() |
53 | const group = this.allLanguagesGroup | 47 | this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy |
54 | 48 | ||
55 | this.languageItems = [ { label: $localize`Unknown language`, id: '_unknown', group } ] | 49 | this.form.patchValue({ |
56 | this.languageItems = this.languageItems | 50 | nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy, |
57 | .concat(languages.map(l => ({ label: l.label, id: l.id, group }))) | 51 | webTorrentEnabled: this.user.webTorrentEnabled, |
58 | 52 | autoPlayVideo: this.user.autoPlayVideo === true, | |
59 | const videoLanguages: ItemSelectCheckboxValue[] = this.user.videoLanguages | 53 | autoPlayNextVideo: this.user.autoPlayNextVideo, |
60 | ? this.user.videoLanguages.map(l => ({ id: l })) | 54 | videoLanguages: this.user.videoLanguages |
61 | : [ { group } ] | 55 | }) |
62 | 56 | ||
63 | const serverConfig = this.serverService.getHTMLConfig() | 57 | if (this.reactiveUpdate) this.handleReactiveUpdate() |
64 | this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy | 58 | } |
65 | 59 | ) | |
66 | this.form.patchValue({ | ||
67 | nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy, | ||
68 | webTorrentEnabled: this.user.webTorrentEnabled, | ||
69 | autoPlayVideo: this.user.autoPlayVideo === true, | ||
70 | autoPlayNextVideo: this.user.autoPlayNextVideo, | ||
71 | videoLanguages | ||
72 | }) | ||
73 | |||
74 | if (this.reactiveUpdate) this.handleReactiveUpdate() | ||
75 | }) | ||
76 | } | 60 | } |
77 | 61 | ||
78 | ngOnDestroy () { | 62 | ngOnDestroy () { |
@@ -85,23 +69,15 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, | |||
85 | const autoPlayVideo = this.form.value['autoPlayVideo'] | 69 | const autoPlayVideo = this.form.value['autoPlayVideo'] |
86 | const autoPlayNextVideo = this.form.value['autoPlayNextVideo'] | 70 | const autoPlayNextVideo = this.form.value['autoPlayNextVideo'] |
87 | 71 | ||
88 | let videoLanguagesForm = this.form.value['videoLanguages'] | 72 | const videoLanguages = this.form.value['videoLanguages'] |
89 | 73 | ||
90 | if (Array.isArray(videoLanguagesForm)) { | 74 | if (Array.isArray(videoLanguages)) { |
91 | if (videoLanguagesForm.length > 20) { | 75 | if (videoLanguages.length > 20) { |
92 | this.notifier.error($localize`Too many languages are enabled. Please enable them all or stay below 20 enabled languages.`) | 76 | this.notifier.error($localize`Too many languages are enabled. Please enable them all or stay below 20 enabled languages.`) |
93 | return | 77 | return |
94 | } | 78 | } |
95 | |||
96 | // Automatically use "All languages" if the user did not select any language | ||
97 | if (videoLanguagesForm.length === 0) { | ||
98 | videoLanguagesForm = [ this.allLanguagesGroup ] | ||
99 | this.form.patchValue({ videoLanguages: [ { group: this.allLanguagesGroup } ] }) | ||
100 | } | ||
101 | } | 79 | } |
102 | 80 | ||
103 | const videoLanguages = this.buildLanguagesFromForm(videoLanguagesForm) | ||
104 | |||
105 | let details: UserUpdateMe = { | 81 | let details: UserUpdateMe = { |
106 | nsfwPolicy, | 82 | nsfwPolicy, |
107 | webTorrentEnabled, | 83 | webTorrentEnabled, |
@@ -123,31 +99,6 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, | |||
123 | return this.updateAnonymousProfile(details) | 99 | return this.updateAnonymousProfile(details) |
124 | } | 100 | } |
125 | 101 | ||
126 | private buildLanguagesFromForm (videoLanguages: ItemSelectCheckboxValue[]) { | ||
127 | if (!Array.isArray(videoLanguages)) return undefined | ||
128 | |||
129 | // null means "All" | ||
130 | if (videoLanguages.length === this.languageItems.length) return null | ||
131 | |||
132 | if (videoLanguages.length === 1) { | ||
133 | const videoLanguage = videoLanguages[0] | ||
134 | |||
135 | if (typeof videoLanguage === 'string') { | ||
136 | if (videoLanguage === this.allLanguagesGroup) return null | ||
137 | } else { | ||
138 | if (videoLanguage.group === this.allLanguagesGroup) return null | ||
139 | } | ||
140 | } | ||
141 | |||
142 | return videoLanguages.map(l => { | ||
143 | if (typeof l === 'string') return l | ||
144 | |||
145 | if (l.group) return l.group | ||
146 | |||
147 | return l.id + '' | ||
148 | }) | ||
149 | } | ||
150 | |||
151 | private handleReactiveUpdate () { | 102 | private handleReactiveUpdate () { |
152 | let oldForm = { ...this.form.value } | 103 | let oldForm = { ...this.form.value } |
153 | 104 | ||
diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.ts b/client/src/app/shared/shared-video-miniature/abstract-video-list.ts deleted file mode 100644 index f12ae2ee5..000000000 --- a/client/src/app/shared/shared-video-miniature/abstract-video-list.ts +++ /dev/null | |||
@@ -1,404 +0,0 @@ | |||
1 | import { fromEvent, Observable, ReplaySubject, Subject, Subscription } from 'rxjs' | ||
2 | import { debounceTime, switchMap, tap } from 'rxjs/operators' | ||
3 | import { | ||
4 | AfterContentInit, | ||
5 | ComponentFactoryResolver, | ||
6 | Directive, | ||
7 | Injector, | ||
8 | OnDestroy, | ||
9 | OnInit, | ||
10 | Type, | ||
11 | ViewChild, | ||
12 | ViewContainerRef | ||
13 | } from '@angular/core' | ||
14 | import { ActivatedRoute, Params, Router } from '@angular/router' | ||
15 | import { | ||
16 | AuthService, | ||
17 | ComponentPaginationLight, | ||
18 | LocalStorageService, | ||
19 | Notifier, | ||
20 | ScreenService, | ||
21 | ServerService, | ||
22 | User, | ||
23 | UserService | ||
24 | } from '@app/core' | ||
25 | import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' | ||
26 | import { GlobalIconName } from '@app/shared/shared-icons' | ||
27 | import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils' | ||
28 | import { HTMLServerConfig, UserRight, VideoFilter, VideoSortField } from '@shared/models' | ||
29 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' | ||
30 | import { Syndication, Video } from '../shared-main' | ||
31 | import { GenericHeaderComponent, VideoListHeaderComponent } from './video-list-header.component' | ||
32 | import { MiniatureDisplayOptions } from './video-miniature.component' | ||
33 | |||
34 | enum GroupDate { | ||
35 | UNKNOWN = 0, | ||
36 | TODAY = 1, | ||
37 | YESTERDAY = 2, | ||
38 | THIS_WEEK = 3, | ||
39 | THIS_MONTH = 4, | ||
40 | LAST_MONTH = 5, | ||
41 | OLDER = 6 | ||
42 | } | ||
43 | |||
44 | @Directive() | ||
45 | // eslint-disable-next-line @angular-eslint/directive-class-suffix | ||
46 | export abstract class AbstractVideoList implements OnInit, OnDestroy, AfterContentInit, DisableForReuseHook { | ||
47 | @ViewChild('videoListHeader', { static: true, read: ViewContainerRef }) videoListHeader: ViewContainerRef | ||
48 | |||
49 | HeaderComponent: Type<GenericHeaderComponent> = VideoListHeaderComponent | ||
50 | headerComponentInjector: Injector | ||
51 | |||
52 | pagination: ComponentPaginationLight = { | ||
53 | currentPage: 1, | ||
54 | itemsPerPage: 25 | ||
55 | } | ||
56 | sort: VideoSortField = '-publishedAt' | ||
57 | |||
58 | categoryOneOf?: number[] | ||
59 | languageOneOf?: string[] | ||
60 | nsfwPolicy?: NSFWPolicyType | ||
61 | defaultSort: VideoSortField = '-publishedAt' | ||
62 | |||
63 | syndicationItems: Syndication[] = [] | ||
64 | |||
65 | loadOnInit = true | ||
66 | loadUserVideoPreferences = false | ||
67 | |||
68 | displayModerationBlock = false | ||
69 | titleTooltip: string | ||
70 | displayVideoActions = true | ||
71 | groupByDate = false | ||
72 | |||
73 | videos: Video[] = [] | ||
74 | hasDoneFirstQuery = false | ||
75 | disabled = false | ||
76 | |||
77 | displayOptions: MiniatureDisplayOptions = { | ||
78 | date: true, | ||
79 | views: true, | ||
80 | by: true, | ||
81 | avatar: false, | ||
82 | privacyLabel: true, | ||
83 | privacyText: false, | ||
84 | state: false, | ||
85 | blacklistInfo: false | ||
86 | } | ||
87 | |||
88 | actions: { | ||
89 | iconName: GlobalIconName | ||
90 | label: string | ||
91 | justIcon?: boolean | ||
92 | routerLink?: string | ||
93 | href?: string | ||
94 | click?: (e: Event) => void | ||
95 | }[] = [] | ||
96 | |||
97 | onDataSubject = new Subject<any[]>() | ||
98 | |||
99 | userMiniature: User | ||
100 | |||
101 | protected onUserLoadedSubject = new ReplaySubject<void>(1) | ||
102 | |||
103 | protected serverConfig: HTMLServerConfig | ||
104 | |||
105 | protected abstract notifier: Notifier | ||
106 | protected abstract authService: AuthService | ||
107 | protected abstract userService: UserService | ||
108 | protected abstract route: ActivatedRoute | ||
109 | protected abstract serverService: ServerService | ||
110 | protected abstract screenService: ScreenService | ||
111 | protected abstract storageService: LocalStorageService | ||
112 | protected abstract router: Router | ||
113 | protected abstract cfr: ComponentFactoryResolver | ||
114 | abstract titlePage: string | ||
115 | |||
116 | private resizeSubscription: Subscription | ||
117 | private angularState: number | ||
118 | |||
119 | private groupedDateLabels: { [id in GroupDate]: string } | ||
120 | private groupedDates: { [id: number]: GroupDate } = {} | ||
121 | |||
122 | private lastQueryLength: number | ||
123 | |||
124 | abstract getVideosObservable (page: number): Observable<{ data: Video[] }> | ||
125 | |||
126 | abstract generateSyndicationList (): void | ||
127 | |||
128 | ngOnInit () { | ||
129 | this.serverConfig = this.serverService.getHTMLConfig() | ||
130 | |||
131 | this.groupedDateLabels = { | ||
132 | [GroupDate.UNKNOWN]: null, | ||
133 | [GroupDate.TODAY]: $localize`Today`, | ||
134 | [GroupDate.YESTERDAY]: $localize`Yesterday`, | ||
135 | [GroupDate.THIS_WEEK]: $localize`This week`, | ||
136 | [GroupDate.THIS_MONTH]: $localize`This month`, | ||
137 | [GroupDate.LAST_MONTH]: $localize`Last month`, | ||
138 | [GroupDate.OLDER]: $localize`Older` | ||
139 | } | ||
140 | |||
141 | // Subscribe to route changes | ||
142 | const routeParams = this.route.snapshot.queryParams | ||
143 | this.loadRouteParams(routeParams) | ||
144 | |||
145 | this.resizeSubscription = fromEvent(window, 'resize') | ||
146 | .pipe(debounceTime(500)) | ||
147 | .subscribe(() => this.calcPageSizes()) | ||
148 | |||
149 | this.calcPageSizes() | ||
150 | |||
151 | const loadUserObservable = this.loadUserAndSettings() | ||
152 | loadUserObservable.subscribe(() => { | ||
153 | this.onUserLoadedSubject.next() | ||
154 | |||
155 | if (this.loadOnInit === true) this.loadMoreVideos() | ||
156 | }) | ||
157 | |||
158 | this.userService.listenAnonymousUpdate() | ||
159 | .pipe(switchMap(() => this.loadUserAndSettings())) | ||
160 | .subscribe(() => { | ||
161 | if (this.hasDoneFirstQuery) this.reloadVideos() | ||
162 | }) | ||
163 | |||
164 | // Display avatar in mobile view | ||
165 | if (this.screenService.isInMobileView()) { | ||
166 | this.displayOptions.avatar = true | ||
167 | } | ||
168 | } | ||
169 | |||
170 | ngOnDestroy () { | ||
171 | if (this.resizeSubscription) this.resizeSubscription.unsubscribe() | ||
172 | } | ||
173 | |||
174 | ngAfterContentInit () { | ||
175 | if (this.videoListHeader) { | ||
176 | // some components don't use the header: they use their own template, like my-history.component.html | ||
177 | this.setHeader(this.HeaderComponent, this.headerComponentInjector) | ||
178 | } | ||
179 | } | ||
180 | |||
181 | disableForReuse () { | ||
182 | this.disabled = true | ||
183 | } | ||
184 | |||
185 | enabledForReuse () { | ||
186 | this.disabled = false | ||
187 | } | ||
188 | |||
189 | videoById (index: number, video: Video) { | ||
190 | return video.id | ||
191 | } | ||
192 | |||
193 | onNearOfBottom () { | ||
194 | if (this.disabled) return | ||
195 | |||
196 | // No more results | ||
197 | if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return | ||
198 | |||
199 | this.pagination.currentPage += 1 | ||
200 | |||
201 | this.setScrollRouteParams() | ||
202 | |||
203 | this.loadMoreVideos() | ||
204 | } | ||
205 | |||
206 | loadMoreVideos (reset = false) { | ||
207 | this.getVideosObservable(this.pagination.currentPage) | ||
208 | .subscribe({ | ||
209 | next: ({ data }) => { | ||
210 | this.hasDoneFirstQuery = true | ||
211 | this.lastQueryLength = data.length | ||
212 | |||
213 | if (reset) this.videos = [] | ||
214 | this.videos = this.videos.concat(data) | ||
215 | |||
216 | if (this.groupByDate) this.buildGroupedDateLabels() | ||
217 | |||
218 | this.onMoreVideos() | ||
219 | |||
220 | this.onDataSubject.next(data) | ||
221 | }, | ||
222 | |||
223 | error: err => { | ||
224 | const message = $localize`Cannot load more videos. Try again later.` | ||
225 | |||
226 | console.error(message, { err }) | ||
227 | this.notifier.error(message) | ||
228 | } | ||
229 | }) | ||
230 | } | ||
231 | |||
232 | reloadVideos () { | ||
233 | this.pagination.currentPage = 1 | ||
234 | this.loadMoreVideos(true) | ||
235 | } | ||
236 | |||
237 | removeVideoFromArray (video: Video) { | ||
238 | this.videos = this.videos.filter(v => v.id !== video.id) | ||
239 | } | ||
240 | |||
241 | buildGroupedDateLabels () { | ||
242 | let currentGroupedDate: GroupDate = GroupDate.UNKNOWN | ||
243 | |||
244 | const periods = [ | ||
245 | { | ||
246 | value: GroupDate.TODAY, | ||
247 | validator: (d: Date) => isToday(d) | ||
248 | }, | ||
249 | { | ||
250 | value: GroupDate.YESTERDAY, | ||
251 | validator: (d: Date) => isYesterday(d) | ||
252 | }, | ||
253 | { | ||
254 | value: GroupDate.THIS_WEEK, | ||
255 | validator: (d: Date) => isLastWeek(d) | ||
256 | }, | ||
257 | { | ||
258 | value: GroupDate.THIS_MONTH, | ||
259 | validator: (d: Date) => isThisMonth(d) | ||
260 | }, | ||
261 | { | ||
262 | value: GroupDate.LAST_MONTH, | ||
263 | validator: (d: Date) => isLastMonth(d) | ||
264 | }, | ||
265 | { | ||
266 | value: GroupDate.OLDER, | ||
267 | validator: () => true | ||
268 | } | ||
269 | ] | ||
270 | |||
271 | for (const video of this.videos) { | ||
272 | const publishedDate = video.publishedAt | ||
273 | |||
274 | for (let i = 0; i < periods.length; i++) { | ||
275 | const period = periods[i] | ||
276 | |||
277 | if (currentGroupedDate <= period.value && period.validator(publishedDate)) { | ||
278 | |||
279 | if (currentGroupedDate !== period.value) { | ||
280 | currentGroupedDate = period.value | ||
281 | this.groupedDates[video.id] = currentGroupedDate | ||
282 | } | ||
283 | |||
284 | break | ||
285 | } | ||
286 | } | ||
287 | } | ||
288 | } | ||
289 | |||
290 | getCurrentGroupedDateLabel (video: Video) { | ||
291 | if (this.groupByDate === false) return undefined | ||
292 | |||
293 | return this.groupedDateLabels[this.groupedDates[video.id]] | ||
294 | } | ||
295 | |||
296 | toggleModerationDisplay () { | ||
297 | throw new Error('toggleModerationDisplay ' + $localize`function is not implemented`) | ||
298 | } | ||
299 | |||
300 | setHeader ( | ||
301 | t: Type<any> = this.HeaderComponent, | ||
302 | i: Injector = this.headerComponentInjector | ||
303 | ) { | ||
304 | const injector = i || Injector.create({ | ||
305 | providers: [ { | ||
306 | provide: 'data', | ||
307 | useValue: { | ||
308 | titlePage: this.titlePage, | ||
309 | titleTooltip: this.titleTooltip | ||
310 | } | ||
311 | } ] | ||
312 | }) | ||
313 | const viewContainerRef = this.videoListHeader | ||
314 | viewContainerRef.clear() | ||
315 | |||
316 | const componentFactory = this.cfr.resolveComponentFactory(t) | ||
317 | viewContainerRef.createComponent(componentFactory, 0, injector) | ||
318 | } | ||
319 | |||
320 | // Can be redefined by child | ||
321 | displayAsRow () { | ||
322 | return false | ||
323 | } | ||
324 | |||
325 | // On videos hook for children that want to do something | ||
326 | protected onMoreVideos () { /* empty */ } | ||
327 | |||
328 | protected load () { /* empty */ } | ||
329 | |||
330 | // Hook if the page has custom route params | ||
331 | protected loadPageRouteParams (_queryParams: Params) { /* empty */ } | ||
332 | |||
333 | protected loadRouteParams (queryParams: Params) { | ||
334 | this.sort = queryParams['sort'] as VideoSortField || this.defaultSort | ||
335 | this.categoryOneOf = queryParams['categoryOneOf'] | ||
336 | this.angularState = queryParams['a-state'] | ||
337 | |||
338 | this.loadPageRouteParams(queryParams) | ||
339 | } | ||
340 | |||
341 | protected buildLocalFilter (existing: VideoFilter, base: VideoFilter) { | ||
342 | if (base === 'local') { | ||
343 | return existing === 'local' | ||
344 | ? 'all-local' as 'all-local' | ||
345 | : 'local' as 'local' | ||
346 | } | ||
347 | |||
348 | return existing === 'all' | ||
349 | ? null | ||
350 | : 'all' | ||
351 | } | ||
352 | |||
353 | protected enableAllFilterIfPossible () { | ||
354 | if (!this.authService.isLoggedIn()) return | ||
355 | |||
356 | this.authService.userInformationLoaded | ||
357 | .subscribe(() => { | ||
358 | const user = this.authService.getUser() | ||
359 | this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS) | ||
360 | }) | ||
361 | } | ||
362 | |||
363 | private calcPageSizes () { | ||
364 | if (this.screenService.isInMobileView()) { | ||
365 | this.pagination.itemsPerPage = 5 | ||
366 | } | ||
367 | } | ||
368 | |||
369 | private setScrollRouteParams () { | ||
370 | // Already set | ||
371 | if (this.angularState) return | ||
372 | |||
373 | this.angularState = 42 | ||
374 | |||
375 | const queryParams = { | ||
376 | 'a-state': this.angularState, | ||
377 | categoryOneOf: this.categoryOneOf | ||
378 | } | ||
379 | |||
380 | let path = this.getUrlWithoutParams() | ||
381 | if (!path || path === '/') path = this.serverConfig.instance.defaultClientRoute | ||
382 | |||
383 | this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' }) | ||
384 | } | ||
385 | |||
386 | private loadUserAndSettings () { | ||
387 | return this.userService.getAnonymousOrLoggedUser() | ||
388 | .pipe(tap(user => { | ||
389 | this.userMiniature = user | ||
390 | |||
391 | if (!this.loadUserVideoPreferences) return | ||
392 | |||
393 | this.languageOneOf = user.videoLanguages | ||
394 | this.nsfwPolicy = user.nsfwPolicy | ||
395 | })) | ||
396 | } | ||
397 | |||
398 | private getUrlWithoutParams () { | ||
399 | const urlTree = this.router.parseUrl(this.router.url) | ||
400 | urlTree.queryParams = {} | ||
401 | |||
402 | return urlTree.toString() | ||
403 | } | ||
404 | } | ||
diff --git a/client/src/app/shared/shared-video-miniature/index.ts b/client/src/app/shared/shared-video-miniature/index.ts index a8fd82bb9..0086d8e6a 100644 --- a/client/src/app/shared/shared-video-miniature/index.ts +++ b/client/src/app/shared/shared-video-miniature/index.ts | |||
@@ -1,7 +1,8 @@ | |||
1 | export * from './abstract-video-list' | ||
2 | export * from './video-actions-dropdown.component' | 1 | export * from './video-actions-dropdown.component' |
3 | export * from './video-download.component' | 2 | export * from './video-download.component' |
3 | export * from './video-filters-header.component' | ||
4 | export * from './video-filters.model' | ||
4 | export * from './video-miniature.component' | 5 | export * from './video-miniature.component' |
6 | export * from './videos-list.component' | ||
5 | export * from './videos-selection.component' | 7 | export * from './videos-selection.component' |
6 | export * from './video-list-header.component' | ||
7 | export * from './shared-video-miniature.module' | 8 | export * from './shared-video-miniature.module' |
diff --git a/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts b/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts index 03be6d2ff..632213922 100644 --- a/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts +++ b/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts | |||
@@ -1,19 +1,20 @@ | |||
1 | 1 | ||
2 | import { NgModule } from '@angular/core' | 2 | import { NgModule } from '@angular/core' |
3 | import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module' | ||
3 | import { SharedFormModule } from '../shared-forms' | 4 | import { SharedFormModule } from '../shared-forms' |
4 | import { SharedGlobalIconModule } from '../shared-icons' | 5 | import { SharedGlobalIconModule } from '../shared-icons' |
5 | import { SharedMainModule } from '../shared-main/shared-main.module' | 6 | import { SharedMainModule } from '../shared-main/shared-main.module' |
6 | import { SharedModerationModule } from '../shared-moderation' | 7 | import { SharedModerationModule } from '../shared-moderation' |
7 | import { SharedVideoModule } from '../shared-video' | ||
8 | import { SharedThumbnailModule } from '../shared-thumbnail' | 8 | import { SharedThumbnailModule } from '../shared-thumbnail' |
9 | import { SharedVideoModule } from '../shared-video' | ||
9 | import { SharedVideoLiveModule } from '../shared-video-live' | 10 | import { SharedVideoLiveModule } from '../shared-video-live' |
10 | import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module' | 11 | import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module' |
11 | import { VideoActionsDropdownComponent } from './video-actions-dropdown.component' | 12 | import { VideoActionsDropdownComponent } from './video-actions-dropdown.component' |
12 | import { VideoDownloadComponent } from './video-download.component' | 13 | import { VideoDownloadComponent } from './video-download.component' |
14 | import { VideoFiltersHeaderComponent } from './video-filters-header.component' | ||
13 | import { VideoMiniatureComponent } from './video-miniature.component' | 15 | import { VideoMiniatureComponent } from './video-miniature.component' |
16 | import { VideosListComponent } from './videos-list.component' | ||
14 | import { VideosSelectionComponent } from './videos-selection.component' | 17 | import { VideosSelectionComponent } from './videos-selection.component' |
15 | import { VideoListHeaderComponent } from './video-list-header.component' | ||
16 | import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module' | ||
17 | 18 | ||
18 | @NgModule({ | 19 | @NgModule({ |
19 | imports: [ | 20 | imports: [ |
@@ -33,14 +34,17 @@ import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image | |||
33 | VideoDownloadComponent, | 34 | VideoDownloadComponent, |
34 | VideoMiniatureComponent, | 35 | VideoMiniatureComponent, |
35 | VideosSelectionComponent, | 36 | VideosSelectionComponent, |
36 | VideoListHeaderComponent | 37 | VideoFiltersHeaderComponent, |
38 | VideosListComponent | ||
37 | ], | 39 | ], |
38 | 40 | ||
39 | exports: [ | 41 | exports: [ |
40 | VideoActionsDropdownComponent, | 42 | VideoActionsDropdownComponent, |
41 | VideoDownloadComponent, | 43 | VideoDownloadComponent, |
42 | VideoMiniatureComponent, | 44 | VideoMiniatureComponent, |
43 | VideosSelectionComponent | 45 | VideosSelectionComponent, |
46 | VideoFiltersHeaderComponent, | ||
47 | VideosListComponent | ||
44 | ], | 48 | ], |
45 | 49 | ||
46 | providers: [ ] | 50 | providers: [ ] |
diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.scss b/client/src/app/shared/shared-video-miniature/video-download.component.scss index c986228d9..bd42f4813 100644 --- a/client/src/app/shared/shared-video-miniature/video-download.component.scss +++ b/client/src/app/shared/shared-video-miniature/video-download.component.scss | |||
@@ -39,7 +39,6 @@ | |||
39 | margin-top: 20px; | 39 | margin-top: 20px; |
40 | 40 | ||
41 | .peertube-radio-container { | 41 | .peertube-radio-container { |
42 | @include peertube-radio-container; | ||
43 | @include margin-right(30px); | 42 | @include margin-right(30px); |
44 | 43 | ||
45 | display: inline-block; | 44 | display: inline-block; |
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html new file mode 100644 index 000000000..44c21c089 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html | |||
@@ -0,0 +1,131 @@ | |||
1 | <ng-template #updateSettings let-fragment> | ||
2 | <div class="label-description text-muted" i18n> | ||
3 | Update | ||
4 | <a routerLink="/my-account/settings" [fragment]="fragment"> | ||
5 | <span (click)="onAccountSettingsClick($event)">your settings</span> | ||
6 | </a | ||
7 | ></div> | ||
8 | </ng-template> | ||
9 | |||
10 | |||
11 | <div class="root" [formGroup]="form"> | ||
12 | |||
13 | <div class="first-row"> | ||
14 | <div class="active-filters"> | ||
15 | <div | ||
16 | class="pastille filters-toggle" (click)="areFiltersCollapsed = !areFiltersCollapsed" role="button" | ||
17 | [attr.aria-expanded]="!areFiltersCollapsed" aria-controls="collapseBasic" | ||
18 | [ngClass]="{ active: !areFiltersCollapsed }" | ||
19 | > | ||
20 | <ng-container i18n *ngIf="areFiltersCollapsed">More filters</ng-container> | ||
21 | <ng-container i18n *ngIf="!areFiltersCollapsed">Less filters</ng-container> | ||
22 | |||
23 | <my-global-icon iconName="chevrons-up"></my-global-icon> | ||
24 | </div> | ||
25 | |||
26 | <div | ||
27 | *ngFor="let activeFilter of filters.getActiveFilters()" (click)="resetFilter(activeFilter.key, activeFilter.canRemove)" | ||
28 | class="active-filter pastille" [ngClass]="{ 'can-remove': activeFilter.canRemove }" [title]="getFilterTitle(activeFilter.canRemove)" | ||
29 | > | ||
30 | <span> | ||
31 | {{ activeFilter.label }} | ||
32 | |||
33 | <ng-container *ngIf="activeFilter.value">: {{ activeFilter.value }}</ng-container> | ||
34 | </span> | ||
35 | |||
36 | <my-global-icon *ngIf="activeFilter.canRemove" iconName="cross"></my-global-icon> | ||
37 | </div> | ||
38 | </div> | ||
39 | |||
40 | <ng-select | ||
41 | class="sort" | ||
42 | formControlName="sort" | ||
43 | [clearable]="false" | ||
44 | [searchable]="false" | ||
45 | > | ||
46 | <ng-option i18n value="-publishedAt">Sort by <strong>"Recently Added"</strong></ng-option> | ||
47 | |||
48 | <ng-option i18n *ngIf="isTrendingSortEnabled('most-viewed')" value="-trending">Sort by <strong>"Views"</strong></ng-option> | ||
49 | <ng-option i18n *ngIf="isTrendingSortEnabled('hot')" value="-hot">Sort by <strong>"Hot"</strong></ng-option> | ||
50 | <ng-option i18n *ngIf="isTrendingSortEnabled('best')" value="-best">Sort by <strong>"Best"</strong></ng-option> | ||
51 | <ng-option i18n *ngIf="isTrendingSortEnabled('most-liked')" value="-likes">Sort by <strong>"Likes"</strong></ng-option> | ||
52 | </ng-select> | ||
53 | |||
54 | </div> | ||
55 | |||
56 | <div class="collapse-transition" [ngbCollapse]="areFiltersCollapsed"> | ||
57 | <div class="filters"> | ||
58 | <div class="form-group"> | ||
59 | <label class="with-description" for="languageOneOf" i18n>Languages:</label> | ||
60 | <ng-template *ngTemplateOutlet="updateSettings; context: { $implicit: 'video-languages-subtitles' }"></ng-template> | ||
61 | |||
62 | <my-select-languages [maxLanguages]="20" formControlName="languageOneOf"></my-select-languages> | ||
63 | </div> | ||
64 | |||
65 | <div class="form-group"> | ||
66 | <label class="with-description" for="nsfw" i18n>Sensitive content:</label> | ||
67 | <ng-template *ngTemplateOutlet="updateSettings; context: { $implicit: 'video-sensitive-content-policy' }"></ng-template> | ||
68 | |||
69 | <div class="peertube-radio-container"> | ||
70 | <input formControlName="nsfw" type="radio" name="nsfw" id="nsfwBoth" i18n-value value="both" /> | ||
71 | <label for="nsfwBoth">{{ filters.getNSFWDisplayLabel() }}</label> | ||
72 | </div> | ||
73 | |||
74 | <div class="peertube-radio-container"> | ||
75 | <input formControlName="nsfw" type="radio" name="nsfw" id="nsfwFalse" i18n-value value="false" /> | ||
76 | <label for="nsfwFalse" i18n>Hide</label> | ||
77 | </div> | ||
78 | </div> | ||
79 | |||
80 | <div class="form-group"> | ||
81 | <label for="scope" i18n>Scope:</label> | ||
82 | |||
83 | <div class="peertube-radio-container"> | ||
84 | <input formControlName="scope" type="radio" name="scope" id="scopeLocal" i18n-value value="local" /> | ||
85 | <label for="scopeLocal" i18n>Local videos (this instance)</label> | ||
86 | </div> | ||
87 | |||
88 | <div class="peertube-radio-container"> | ||
89 | <input formControlName="scope" type="radio" name="scope" id="scopeFederated" i18n-value value="federated" /> | ||
90 | <label for="scopeFederated" i18n>Federated videos (this instance + followed instances)</label> | ||
91 | </div> | ||
92 | </div> | ||
93 | |||
94 | <div class="form-group"> | ||
95 | <label for="type" i18n>Type:</label> | ||
96 | |||
97 | <div class="peertube-radio-container"> | ||
98 | <input formControlName="live" type="radio" name="live" id="liveBoth" i18n-value value="both" /> | ||
99 | <label for="liveBoth" i18n>VOD & Live videos</label> | ||
100 | </div> | ||
101 | |||
102 | <div class="peertube-radio-container"> | ||
103 | <input formControlName="live" type="radio" name="live" id="liveTrue" i18n-value value="true" /> | ||
104 | <label for="liveTrue" i18n>Live videos</label> | ||
105 | </div> | ||
106 | |||
107 | <div class="peertube-radio-container"> | ||
108 | <input formControlName="live" type="radio" name="live" id="liveFalse" i18n-value value="false" /> | ||
109 | <label for="liveFalse" i18n>VOD videos</label> | ||
110 | </div> | ||
111 | </div> | ||
112 | |||
113 | <div class="form-group"> | ||
114 | <label for="categoryOneOf" i18n>Categories:</label> | ||
115 | |||
116 | <my-select-categories formControlName="categoryOneOf"></my-select-categories> | ||
117 | </div> | ||
118 | |||
119 | <div class="form-group" *ngIf="canSeeAllVideos()"> | ||
120 | <label for="allVideos" i18n>Moderation:</label> | ||
121 | |||
122 | <my-peertube-checkbox | ||
123 | formControlName="allVideos" | ||
124 | inputName="allVideos" | ||
125 | i18n-labelText labelText="Display all videos (private, unlisted or not yet published)" | ||
126 | ></my-peertube-checkbox> | ||
127 | </div> | ||
128 | </div> | ||
129 | </div> | ||
130 | |||
131 | </div> | ||
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss new file mode 100644 index 000000000..8cb1ff5b8 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss | |||
@@ -0,0 +1,139 @@ | |||
1 | @use '_variables' as *; | ||
2 | @use '_mixins' as *; | ||
3 | |||
4 | .root { | ||
5 | margin-bottom: 45px; | ||
6 | font-size: 15px; | ||
7 | } | ||
8 | |||
9 | .first-row { | ||
10 | display: flex; | ||
11 | justify-content: space-between; | ||
12 | } | ||
13 | |||
14 | .active-filters { | ||
15 | display: flex; | ||
16 | flex-wrap: wrap; | ||
17 | } | ||
18 | |||
19 | .filters { | ||
20 | display: flex; | ||
21 | flex-wrap: wrap; | ||
22 | margin-top: 25px; | ||
23 | |||
24 | border-bottom: 1px solid $separator-border-color; | ||
25 | |||
26 | input[type=radio] + label { | ||
27 | font-weight: $font-regular; | ||
28 | } | ||
29 | |||
30 | .form-group > label:first-child { | ||
31 | display: block; | ||
32 | |||
33 | &.with-description { | ||
34 | margin-bottom: 0; | ||
35 | } | ||
36 | |||
37 | &:not(.with-description) { | ||
38 | margin-bottom: 10px; | ||
39 | } | ||
40 | } | ||
41 | |||
42 | .form-group { | ||
43 | @include margin-right(30px); | ||
44 | } | ||
45 | } | ||
46 | |||
47 | .pastille { | ||
48 | @include margin-right(15px); | ||
49 | |||
50 | border-radius: 24px; | ||
51 | padding: 4px 15px; | ||
52 | font-size: 16px; | ||
53 | margin-bottom: 15px; | ||
54 | cursor: pointer; | ||
55 | } | ||
56 | |||
57 | .filters-toggle { | ||
58 | border: 2px solid pvar(--mainForegroundColor); | ||
59 | |||
60 | my-global-icon { | ||
61 | @include margin-left(5px); | ||
62 | } | ||
63 | |||
64 | &.active my-global-icon { | ||
65 | position: relative; | ||
66 | top: -1px; | ||
67 | } | ||
68 | |||
69 | &:not(.active) { | ||
70 | my-global-icon ::ng-deep svg { | ||
71 | transform: rotate(180deg); | ||
72 | } | ||
73 | } | ||
74 | } | ||
75 | |||
76 | // Than have an icon | ||
77 | .filters-toggle, | ||
78 | .active-filter.can-remove { | ||
79 | padding: 4px 11px 4px 15px; | ||
80 | } | ||
81 | |||
82 | .active-filter { | ||
83 | background-color: pvar(--channelBackgroundColor); | ||
84 | display: flex; | ||
85 | align-items: center; | ||
86 | |||
87 | &:not(.can-remove) { | ||
88 | cursor: default; | ||
89 | } | ||
90 | |||
91 | &.can-remove:hover { | ||
92 | opacity: 0.9; | ||
93 | } | ||
94 | |||
95 | my-global-icon { | ||
96 | @include margin-left(10px); | ||
97 | |||
98 | width: 16px; | ||
99 | color: pvar(--greyForegroundColor); | ||
100 | } | ||
101 | } | ||
102 | |||
103 | .sort { | ||
104 | min-width: 200px; | ||
105 | max-width: 300px; | ||
106 | height: min-content; | ||
107 | |||
108 | ::ng-deep { | ||
109 | .ng-select-container { | ||
110 | height: 33px !important; | ||
111 | } | ||
112 | |||
113 | .ng-value strong { | ||
114 | @include margin-left(5px); | ||
115 | } | ||
116 | } | ||
117 | } | ||
118 | |||
119 | my-select-languages, | ||
120 | my-select-categories { | ||
121 | width: 300px; | ||
122 | display: inline-block; | ||
123 | } | ||
124 | |||
125 | .label-description { | ||
126 | font-size: 12px; | ||
127 | font-style: italic; | ||
128 | margin-bottom: 10px; | ||
129 | |||
130 | a { | ||
131 | color: pvar(--mainColor); | ||
132 | } | ||
133 | } | ||
134 | |||
135 | @media screen and (max-width: $small-view) { | ||
136 | .first-row { | ||
137 | flex-direction: column; | ||
138 | } | ||
139 | } | ||
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts b/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts new file mode 100644 index 000000000..99f133e54 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts | |||
@@ -0,0 +1,119 @@ | |||
1 | import * as debug from 'debug' | ||
2 | import { Subscription } from 'rxjs' | ||
3 | import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' | ||
4 | import { FormBuilder, FormGroup } from '@angular/forms' | ||
5 | import { AuthService } from '@app/core' | ||
6 | import { ServerService } from '@app/core/server/server.service' | ||
7 | import { UserRight } from '@shared/models' | ||
8 | import { NSFWPolicyType } from '@shared/models/videos' | ||
9 | import { PeertubeModalService } from '../shared-main' | ||
10 | import { VideoFilters } from './video-filters.model' | ||
11 | |||
12 | const logger = debug('peertube:videos:VideoFiltersHeaderComponent') | ||
13 | |||
14 | @Component({ | ||
15 | selector: 'my-video-filters-header', | ||
16 | styleUrls: [ './video-filters-header.component.scss' ], | ||
17 | templateUrl: './video-filters-header.component.html' | ||
18 | }) | ||
19 | export class VideoFiltersHeaderComponent implements OnInit, OnDestroy { | ||
20 | @Input() filters: VideoFilters | ||
21 | |||
22 | @Input() displayModerationBlock = false | ||
23 | |||
24 | @Input() defaultSort = '-publishedAt' | ||
25 | @Input() nsfwPolicy: NSFWPolicyType | ||
26 | |||
27 | @Output() filtersChanged = new EventEmitter() | ||
28 | |||
29 | areFiltersCollapsed = true | ||
30 | |||
31 | form: FormGroup | ||
32 | |||
33 | private routeSub: Subscription | ||
34 | |||
35 | constructor ( | ||
36 | private auth: AuthService, | ||
37 | private serverService: ServerService, | ||
38 | private fb: FormBuilder, | ||
39 | private modalService: PeertubeModalService | ||
40 | ) { | ||
41 | } | ||
42 | |||
43 | ngOnInit () { | ||
44 | this.form = this.fb.group({ | ||
45 | sort: [ '' ], | ||
46 | nsfw: [ '' ], | ||
47 | languageOneOf: [ '' ], | ||
48 | categoryOneOf: [ '' ], | ||
49 | scope: [ '' ], | ||
50 | allVideos: [ '' ], | ||
51 | live: [ '' ] | ||
52 | }) | ||
53 | |||
54 | this.patchForm(false) | ||
55 | |||
56 | this.filters.onChange(() => { | ||
57 | this.patchForm(false) | ||
58 | }) | ||
59 | |||
60 | this.form.valueChanges.subscribe(values => { | ||
61 | logger('Loading values from form: %O', values) | ||
62 | |||
63 | this.filters.load(values) | ||
64 | this.filtersChanged.emit() | ||
65 | }) | ||
66 | } | ||
67 | |||
68 | ngOnDestroy () { | ||
69 | if (this.routeSub) this.routeSub.unsubscribe() | ||
70 | } | ||
71 | |||
72 | canSeeAllVideos () { | ||
73 | if (!this.auth.isLoggedIn()) return false | ||
74 | if (!this.displayModerationBlock) return false | ||
75 | |||
76 | return this.auth.getUser().hasRight(UserRight.SEE_ALL_VIDEOS) | ||
77 | } | ||
78 | |||
79 | isTrendingSortEnabled (sort: 'most-viewed' | 'hot' | 'best' | 'most-liked') { | ||
80 | const serverConfig = this.serverService.getHTMLConfig() | ||
81 | |||
82 | const enabled = serverConfig.trending.videos.algorithms.enabled.includes(sort) | ||
83 | |||
84 | // Best is adapted from the user | ||
85 | if (sort === 'best') return enabled && this.auth.isLoggedIn() | ||
86 | |||
87 | return enabled | ||
88 | } | ||
89 | |||
90 | resetFilter (key: string, canRemove: boolean) { | ||
91 | if (!canRemove) return | ||
92 | |||
93 | this.filters.reset(key) | ||
94 | this.patchForm(false) | ||
95 | this.filtersChanged.emit() | ||
96 | } | ||
97 | |||
98 | getFilterTitle (canRemove: boolean) { | ||
99 | if (canRemove) return $localize`Remove this filter` | ||
100 | |||
101 | return '' | ||
102 | } | ||
103 | |||
104 | onAccountSettingsClick (event: Event) { | ||
105 | if (this.auth.isLoggedIn()) return | ||
106 | |||
107 | event.preventDefault() | ||
108 | event.stopPropagation() | ||
109 | |||
110 | this.modalService.openQuickSettingsSubject.next() | ||
111 | } | ||
112 | |||
113 | private patchForm (emitEvent: boolean) { | ||
114 | const defaultValues = this.filters.toFormObject() | ||
115 | this.form.patchValue(defaultValues, { emitEvent }) | ||
116 | |||
117 | logger('Patched form: %O', defaultValues) | ||
118 | } | ||
119 | } | ||
diff --git a/client/src/app/shared/shared-video-miniature/video-filters.model.ts b/client/src/app/shared/shared-video-miniature/video-filters.model.ts new file mode 100644 index 000000000..a3b8129f0 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-filters.model.ts | |||
@@ -0,0 +1,240 @@ | |||
1 | import { intoArray, toBoolean } from '@app/helpers' | ||
2 | import { AttributesOnly } from '@shared/core-utils' | ||
3 | import { BooleanBothQuery, NSFWPolicyType, VideoFilter, VideoSortField } from '@shared/models' | ||
4 | |||
5 | type VideoFiltersKeys = { | ||
6 | [ id in keyof AttributesOnly<VideoFilters> ]: any | ||
7 | } | ||
8 | |||
9 | export type VideoFilterScope = 'local' | 'federated' | ||
10 | |||
11 | export class VideoFilters { | ||
12 | sort: VideoSortField | ||
13 | nsfw: BooleanBothQuery | ||
14 | |||
15 | languageOneOf: string[] | ||
16 | categoryOneOf: number[] | ||
17 | |||
18 | scope: VideoFilterScope | ||
19 | allVideos: boolean | ||
20 | |||
21 | live: BooleanBothQuery | ||
22 | |||
23 | search: string | ||
24 | |||
25 | private defaultValues = new Map<keyof VideoFilters, any>([ | ||
26 | [ 'sort', '-publishedAt' ], | ||
27 | [ 'nsfw', 'false' ], | ||
28 | [ 'languageOneOf', undefined ], | ||
29 | [ 'categoryOneOf', undefined ], | ||
30 | [ 'scope', 'federated' ], | ||
31 | [ 'allVideos', false ], | ||
32 | [ 'live', 'both' ] | ||
33 | ]) | ||
34 | |||
35 | private activeFilters: { key: string, canRemove: boolean, label: string, value?: string }[] = [] | ||
36 | private defaultNSFWPolicy: NSFWPolicyType | ||
37 | |||
38 | private onChangeCallbacks: Array<() => void> = [] | ||
39 | private oldFormObjectString: string | ||
40 | |||
41 | constructor (defaultSort: string, defaultScope: VideoFilterScope) { | ||
42 | this.setDefaultSort(defaultSort) | ||
43 | this.setDefaultScope(defaultScope) | ||
44 | |||
45 | this.reset() | ||
46 | } | ||
47 | |||
48 | onChange (cb: () => void) { | ||
49 | this.onChangeCallbacks.push(cb) | ||
50 | } | ||
51 | |||
52 | triggerChange () { | ||
53 | // Don't run on change if the values did not change | ||
54 | const currentFormObjectString = JSON.stringify(this.toFormObject()) | ||
55 | if (this.oldFormObjectString && currentFormObjectString === this.oldFormObjectString) return | ||
56 | |||
57 | this.oldFormObjectString = currentFormObjectString | ||
58 | |||
59 | for (const cb of this.onChangeCallbacks) { | ||
60 | cb() | ||
61 | } | ||
62 | } | ||
63 | |||
64 | setDefaultScope (scope: VideoFilterScope) { | ||
65 | this.defaultValues.set('scope', scope) | ||
66 | } | ||
67 | |||
68 | setDefaultSort (sort: string) { | ||
69 | this.defaultValues.set('sort', sort) | ||
70 | } | ||
71 | |||
72 | setNSFWPolicy (nsfwPolicy: NSFWPolicyType) { | ||
73 | this.updateDefaultNSFW(nsfwPolicy) | ||
74 | } | ||
75 | |||
76 | reset (specificKey?: string) { | ||
77 | for (const [ key, value ] of this.defaultValues) { | ||
78 | if (specificKey && specificKey !== key) continue | ||
79 | |||
80 | // FIXME: typings | ||
81 | this[key as any] = value | ||
82 | } | ||
83 | |||
84 | this.buildActiveFilters() | ||
85 | } | ||
86 | |||
87 | load (obj: Partial<AttributesOnly<VideoFilters>>) { | ||
88 | if (obj.sort !== undefined) this.sort = obj.sort | ||
89 | |||
90 | if (obj.nsfw !== undefined) this.nsfw = obj.nsfw | ||
91 | |||
92 | if (obj.languageOneOf !== undefined) this.languageOneOf = intoArray(obj.languageOneOf) | ||
93 | if (obj.categoryOneOf !== undefined) this.categoryOneOf = intoArray(obj.categoryOneOf) | ||
94 | |||
95 | if (obj.scope !== undefined) this.scope = obj.scope | ||
96 | if (obj.allVideos !== undefined) this.allVideos = toBoolean(obj.allVideos) | ||
97 | |||
98 | if (obj.live !== undefined) this.live = obj.live | ||
99 | |||
100 | if (obj.search !== undefined) this.search = obj.search | ||
101 | |||
102 | this.buildActiveFilters() | ||
103 | } | ||
104 | |||
105 | buildActiveFilters () { | ||
106 | this.activeFilters = [] | ||
107 | |||
108 | this.activeFilters.push({ | ||
109 | key: 'nsfw', | ||
110 | canRemove: false, | ||
111 | label: $localize`Sensitive content`, | ||
112 | value: this.getNSFWValue() | ||
113 | }) | ||
114 | |||
115 | this.activeFilters.push({ | ||
116 | key: 'scope', | ||
117 | canRemove: false, | ||
118 | label: $localize`Scope`, | ||
119 | value: this.scope === 'federated' | ||
120 | ? $localize`Federated` | ||
121 | : $localize`Local` | ||
122 | }) | ||
123 | |||
124 | if (this.languageOneOf && this.languageOneOf.length !== 0) { | ||
125 | this.activeFilters.push({ | ||
126 | key: 'languageOneOf', | ||
127 | canRemove: true, | ||
128 | label: $localize`Languages`, | ||
129 | value: this.languageOneOf.map(l => l.toUpperCase()).join(', ') | ||
130 | }) | ||
131 | } | ||
132 | |||
133 | if (this.categoryOneOf && this.categoryOneOf.length !== 0) { | ||
134 | this.activeFilters.push({ | ||
135 | key: 'categoryOneOf', | ||
136 | canRemove: true, | ||
137 | label: $localize`Categories`, | ||
138 | value: this.categoryOneOf.join(', ') | ||
139 | }) | ||
140 | } | ||
141 | |||
142 | if (this.allVideos) { | ||
143 | this.activeFilters.push({ | ||
144 | key: 'allVideos', | ||
145 | canRemove: true, | ||
146 | label: $localize`All videos` | ||
147 | }) | ||
148 | } | ||
149 | |||
150 | if (this.live === 'true') { | ||
151 | this.activeFilters.push({ | ||
152 | key: 'live', | ||
153 | canRemove: true, | ||
154 | label: $localize`Live videos` | ||
155 | }) | ||
156 | } else if (this.live === 'false') { | ||
157 | this.activeFilters.push({ | ||
158 | key: 'live', | ||
159 | canRemove: true, | ||
160 | label: $localize`VOD videos` | ||
161 | }) | ||
162 | } | ||
163 | } | ||
164 | |||
165 | getActiveFilters () { | ||
166 | return this.activeFilters | ||
167 | } | ||
168 | |||
169 | toFormObject (): VideoFiltersKeys { | ||
170 | const result: Partial<VideoFiltersKeys> = {} | ||
171 | |||
172 | for (const [ key ] of this.defaultValues) { | ||
173 | result[key] = this[key] | ||
174 | } | ||
175 | |||
176 | return result as VideoFiltersKeys | ||
177 | } | ||
178 | |||
179 | toUrlObject () { | ||
180 | const result: { [ id: string ]: any } = {} | ||
181 | |||
182 | for (const [ key, defaultValue ] of this.defaultValues) { | ||
183 | if (this[key] !== defaultValue) { | ||
184 | result[key] = this[key] | ||
185 | } | ||
186 | } | ||
187 | |||
188 | return result | ||
189 | } | ||
190 | |||
191 | toVideosAPIObject () { | ||
192 | let filter: VideoFilter | ||
193 | |||
194 | if (this.scope === 'local' && this.allVideos) { | ||
195 | filter = 'all-local' | ||
196 | } else if (this.scope === 'federated' && this.allVideos) { | ||
197 | filter = 'all' | ||
198 | } else if (this.scope === 'local') { | ||
199 | filter = 'local' | ||
200 | } | ||
201 | |||
202 | let isLive: boolean | ||
203 | if (this.live === 'true') isLive = true | ||
204 | else if (this.live === 'false') isLive = false | ||
205 | |||
206 | return { | ||
207 | sort: this.sort, | ||
208 | nsfw: this.nsfw, | ||
209 | languageOneOf: this.languageOneOf, | ||
210 | categoryOneOf: this.categoryOneOf, | ||
211 | search: this.search, | ||
212 | filter, | ||
213 | isLive | ||
214 | } | ||
215 | } | ||
216 | |||
217 | getNSFWDisplayLabel () { | ||
218 | if (this.defaultNSFWPolicy === 'blur') return $localize`Blurred` | ||
219 | |||
220 | return $localize`Displayed` | ||
221 | } | ||
222 | |||
223 | private getNSFWValue () { | ||
224 | if (this.nsfw === 'false') return $localize`hidden` | ||
225 | if (this.defaultNSFWPolicy === 'blur') return $localize`blurred` | ||
226 | |||
227 | return $localize`displayed` | ||
228 | } | ||
229 | |||
230 | private updateDefaultNSFW (nsfwPolicy: NSFWPolicyType) { | ||
231 | const nsfw = nsfwPolicy === 'do_not_list' | ||
232 | ? 'false' | ||
233 | : 'both' | ||
234 | |||
235 | this.defaultValues.set('nsfw', nsfw) | ||
236 | this.defaultNSFWPolicy = nsfwPolicy | ||
237 | |||
238 | this.reset('nsfw') | ||
239 | } | ||
240 | } | ||
diff --git a/client/src/app/shared/shared-video-miniature/video-list-header.component.html b/client/src/app/shared/shared-video-miniature/video-list-header.component.html deleted file mode 100644 index 58db437b8..000000000 --- a/client/src/app/shared/shared-video-miniature/video-list-header.component.html +++ /dev/null | |||
@@ -1,5 +0,0 @@ | |||
1 | <h1 class="title-page title-page-single"> | ||
2 | <div placement="bottom" [ngbTooltip]="data.titleTooltip" container="body"> | ||
3 | {{ data.titlePage }} | ||
4 | </div> | ||
5 | </h1> \ No newline at end of file | ||
diff --git a/client/src/app/shared/shared-video-miniature/video-list-header.component.ts b/client/src/app/shared/shared-video-miniature/video-list-header.component.ts deleted file mode 100644 index fed696672..000000000 --- a/client/src/app/shared/shared-video-miniature/video-list-header.component.ts +++ /dev/null | |||
@@ -1,22 +0,0 @@ | |||
1 | import { Component, Inject, ViewEncapsulation } from '@angular/core' | ||
2 | |||
3 | export interface GenericHeaderData { | ||
4 | titlePage: string | ||
5 | titleTooltip?: string | ||
6 | } | ||
7 | |||
8 | export abstract class GenericHeaderComponent { | ||
9 | constructor (@Inject('data') public data: GenericHeaderData) {} | ||
10 | } | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-video-list-header', | ||
14 | // eslint-disable-next-line @angular-eslint/use-component-view-encapsulation | ||
15 | encapsulation: ViewEncapsulation.None, | ||
16 | templateUrl: './video-list-header.component.html' | ||
17 | }) | ||
18 | export class VideoListHeaderComponent extends GenericHeaderComponent { | ||
19 | constructor (@Inject('data') public data: GenericHeaderData) { | ||
20 | super(data) | ||
21 | } | ||
22 | } | ||
diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.html b/client/src/app/shared/shared-video-miniature/videos-list.component.html index 9ffeac5e8..4ccb4092c 100644 --- a/client/src/app/shared/shared-video-miniature/abstract-video-list.html +++ b/client/src/app/shared/shared-video-miniature/videos-list.component.html | |||
@@ -1,11 +1,17 @@ | |||
1 | <div class="margin-content"> | 1 | <div class="margin-content"> |
2 | <div class="videos-header"> | 2 | <div class="videos-header"> |
3 | <ng-template #videoListHeader></ng-template> | 3 | <h1 *ngIf="displayTitle" class="title" placement="bottom" [ngbTooltip]="titleTooltip" container="body"> |
4 | {{ title }} | ||
5 | </h1> | ||
4 | 6 | ||
5 | <div class="action-block"> | 7 | <div *ngIf="syndicationItems" [ngClass]="{ 'no-title': !displayTitle }" class="title-subscription"> |
6 | <my-feed *ngIf="syndicationItems" [syndicationItems]="syndicationItems"></my-feed> | 8 | <ng-container i18n>Subscribe to RSS feed "{{ title }}"</ng-container> |
9 | |||
10 | <my-feed [syndicationItems]="syndicationItems"></my-feed> | ||
11 | </div> | ||
7 | 12 | ||
8 | <ng-container *ngFor="let action of actions"> | 13 | <div class="action-block"> |
14 | <ng-container *ngFor="let action of headerActions"> | ||
9 | <a *ngIf="action.routerLink" class="ml-2" [routerLink]="action.routerLink" routerLinkActive="active"> | 15 | <a *ngIf="action.routerLink" class="ml-2" [routerLink]="action.routerLink" routerLinkActive="active"> |
10 | <ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container> | 16 | <ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container> |
11 | </a> | 17 | </a> |
@@ -24,27 +30,18 @@ | |||
24 | </ng-template> | 30 | </ng-template> |
25 | </ng-container> | 31 | </ng-container> |
26 | </div> | 32 | </div> |
27 | |||
28 | <div class="moderation-block" *ngIf="displayModerationBlock"> | ||
29 | <div class="c-hand" ngbDropdown placement="bottom-right auto"> | ||
30 | <my-global-icon iconName="cog" ngbDropdownToggle></my-global-icon> | ||
31 | |||
32 | <div role="menu" class="dropdown-menu" ngbDropdownMenu> | ||
33 | <div class="dropdown-item"> | ||
34 | <my-peertube-checkbox | ||
35 | (change)="toggleModerationDisplay()" | ||
36 | inputName="display-unlisted-private" i18n-labelText labelText="Display all videos (private, unlisted or not yet published)" | ||
37 | ></my-peertube-checkbox> | ||
38 | </div> | ||
39 | </div> | ||
40 | </div> | ||
41 | </div> | ||
42 | </div> | 33 | </div> |
43 | 34 | ||
35 | <my-video-filters-header | ||
36 | *ngIf="displayFilters" [displayModerationBlock]="displayModerationBlock" | ||
37 | [defaultSort]="defaultSort" [filters]="filters" | ||
38 | (filtersChanged)="onFiltersChanged(true)" | ||
39 | ></my-video-filters-header> | ||
40 | |||
44 | <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">No results.</div> | 41 | <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">No results.</div> |
45 | <div | 42 | <div |
46 | myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" | 43 | myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" [setAngularState]="true" |
47 | class="videos" [ngClass]="{ 'display-as-row': displayAsRow() }" | 44 | class="videos" [ngClass]="{ 'display-as-row': displayAsRow }" |
48 | > | 45 | > |
49 | <ng-container *ngFor="let video of videos; trackBy: videoById;"> | 46 | <ng-container *ngFor="let video of videos; trackBy: videoById;"> |
50 | <h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)"> | 47 | <h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)"> |
@@ -53,7 +50,7 @@ | |||
53 | 50 | ||
54 | <div class="video-wrapper"> | 51 | <div class="video-wrapper"> |
55 | <my-video-miniature | 52 | <my-video-miniature |
56 | [video]="video" [user]="userMiniature" [displayAsRow]="displayAsRow()" | 53 | [video]="video" [user]="userMiniature" [displayAsRow]="displayAsRow" |
57 | [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions" | 54 | [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions" |
58 | (videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)" | 55 | (videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)" |
59 | > | 56 | > |
diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.scss b/client/src/app/shared/shared-video-miniature/videos-list.component.scss index 79e3c1bdf..e82ef05ba 100644 --- a/client/src/app/shared/shared-video-miniature/abstract-video-list.scss +++ b/client/src/app/shared/shared-video-miniature/videos-list.component.scss | |||
@@ -3,44 +3,57 @@ | |||
3 | @use '_mixins' as *; | 3 | @use '_mixins' as *; |
4 | @use '_miniature' as *; | 4 | @use '_miniature' as *; |
5 | 5 | ||
6 | $icon-size: 16px; | ||
7 | |||
8 | ::ng-deep my-video-list-header { | ||
9 | display: flex; | ||
10 | flex-grow: 1; | ||
11 | } | ||
12 | |||
13 | .videos-header { | 6 | .videos-header { |
14 | display: flex; | 7 | display: grid; |
15 | justify-content: space-between; | 8 | grid-template-columns: auto 1fr auto; |
16 | align-items: center; | 9 | margin-bottom: 30px; |
17 | 10 | ||
18 | my-feed { | 11 | .title, |
19 | display: inline-block; | 12 | .title-subscription { |
20 | width: calc(#{$icon-size} - 2px); | 13 | grid-column: 1; |
21 | } | 14 | } |
22 | 15 | ||
23 | .moderation-block { | 16 | .title { |
24 | @include margin-left(.4rem); | 17 | font-size: 18px; |
18 | color: pvar(--mainForegroundColor); | ||
19 | display: inline-block; | ||
20 | font-weight: $font-semibold; | ||
25 | 21 | ||
26 | display: flex; | 22 | margin-top: 30px; |
27 | justify-content: flex-end; | 23 | margin-bottom: 0; |
28 | align-items: center; | 24 | } |
25 | |||
26 | .title-subscription { | ||
27 | grid-row: 2; | ||
28 | font-size: 14px; | ||
29 | color: pvar(--greyForegroundColor); | ||
29 | 30 | ||
30 | my-global-icon { | 31 | &.no-title { |
31 | position: relative; | 32 | margin-top: 10px; |
32 | width: $icon-size; | ||
33 | } | 33 | } |
34 | } | 34 | } |
35 | |||
36 | .action-block { | ||
37 | grid-column: 3; | ||
38 | } | ||
39 | |||
40 | my-feed { | ||
41 | @include margin-left(5px); | ||
42 | |||
43 | display: inline-block; | ||
44 | width: 16px; | ||
45 | color: pvar(--mainColor); | ||
46 | position: relative; | ||
47 | top: -2px; | ||
48 | } | ||
35 | } | 49 | } |
36 | 50 | ||
37 | .date-title { | 51 | .date-title { |
38 | font-size: 16px; | 52 | font-size: 16px; |
39 | font-weight: $font-semibold; | 53 | font-weight: $font-semibold; |
40 | margin-bottom: 20px; | 54 | margin-bottom: 20px; |
41 | margin-top: -10px; | ||
42 | 55 | ||
43 | // make the element span a full grid row within .videos grid | 56 | // Make the element span a full grid row within .videos grid |
44 | grid-column: 1 / -1; | 57 | grid-column: 1 / -1; |
45 | 58 | ||
46 | &:not(:first-child) { | 59 | &:not(:first-child) { |
@@ -64,6 +77,18 @@ $icon-size: 16px; | |||
64 | } | 77 | } |
65 | 78 | ||
66 | @media screen and (max-width: $mobile-view) { | 79 | @media screen and (max-width: $mobile-view) { |
80 | .videos-header, | ||
81 | my-video-filters-header { | ||
82 | @include margin-left(15px); | ||
83 | @include margin-right(15px); | ||
84 | |||
85 | display: inline-block; | ||
86 | } | ||
87 | |||
88 | .date-title { | ||
89 | text-align: center; | ||
90 | } | ||
91 | |||
67 | .videos-header { | 92 | .videos-header { |
68 | flex-direction: column; | 93 | flex-direction: column; |
69 | align-items: center; | 94 | align-items: center; |
diff --git a/client/src/app/shared/shared-video-miniature/videos-list.component.ts b/client/src/app/shared/shared-video-miniature/videos-list.component.ts new file mode 100644 index 000000000..10de97298 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/videos-list.component.ts | |||
@@ -0,0 +1,396 @@ | |||
1 | import * as debug from 'debug' | ||
2 | import { fromEvent, Observable, Subject, Subscription } from 'rxjs' | ||
3 | import { debounceTime, switchMap } from 'rxjs/operators' | ||
4 | import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core' | ||
5 | import { ActivatedRoute } from '@angular/router' | ||
6 | import { AuthService, ComponentPaginationLight, Notifier, PeerTubeRouterService, ScreenService, User, UserService } from '@app/core' | ||
7 | import { GlobalIconName } from '@app/shared/shared-icons' | ||
8 | import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils' | ||
9 | import { ResultList, UserRight, VideoSortField } from '@shared/models' | ||
10 | import { Syndication, Video } from '../shared-main' | ||
11 | import { VideoFilters, VideoFilterScope } from './video-filters.model' | ||
12 | import { MiniatureDisplayOptions } from './video-miniature.component' | ||
13 | |||
14 | const logger = debug('peertube:videos:VideosListComponent') | ||
15 | |||
16 | export type HeaderAction = { | ||
17 | iconName: GlobalIconName | ||
18 | label: string | ||
19 | justIcon?: boolean | ||
20 | routerLink?: string | ||
21 | href?: string | ||
22 | click?: (e: Event) => void | ||
23 | } | ||
24 | |||
25 | enum GroupDate { | ||
26 | UNKNOWN = 0, | ||
27 | TODAY = 1, | ||
28 | YESTERDAY = 2, | ||
29 | THIS_WEEK = 3, | ||
30 | THIS_MONTH = 4, | ||
31 | LAST_MONTH = 5, | ||
32 | OLDER = 6 | ||
33 | } | ||
34 | |||
35 | @Component({ | ||
36 | selector: 'my-videos-list', | ||
37 | templateUrl: './videos-list.component.html', | ||
38 | styleUrls: [ './videos-list.component.scss' ] | ||
39 | }) | ||
40 | export class VideosListComponent implements OnInit, OnChanges, OnDestroy { | ||
41 | @Input() getVideosObservableFunction: (pagination: ComponentPaginationLight, filters: VideoFilters) => Observable<ResultList<Video>> | ||
42 | @Input() getSyndicationItemsFunction: (filters: VideoFilters) => Promise<Syndication[]> | Syndication[] | ||
43 | @Input() baseRouteBuilderFunction: (filters: VideoFilters) => string[] | ||
44 | |||
45 | @Input() title: string | ||
46 | @Input() titleTooltip: string | ||
47 | @Input() displayTitle = true | ||
48 | |||
49 | @Input() defaultSort: VideoSortField | ||
50 | @Input() defaultScope: VideoFilterScope = 'federated' | ||
51 | @Input() displayFilters = false | ||
52 | @Input() displayModerationBlock = false | ||
53 | |||
54 | @Input() loadUserVideoPreferences = false | ||
55 | |||
56 | @Input() displayAsRow = false | ||
57 | @Input() displayVideoActions = true | ||
58 | @Input() groupByDate = false | ||
59 | |||
60 | @Input() headerActions: HeaderAction[] = [] | ||
61 | |||
62 | @Input() displayOptions: MiniatureDisplayOptions = { | ||
63 | date: true, | ||
64 | views: true, | ||
65 | by: true, | ||
66 | avatar: false, | ||
67 | privacyLabel: true, | ||
68 | privacyText: false, | ||
69 | state: false, | ||
70 | blacklistInfo: false | ||
71 | } | ||
72 | |||
73 | @Input() disabled = false | ||
74 | |||
75 | @Output() filtersChanged = new EventEmitter<VideoFilters>() | ||
76 | |||
77 | videos: Video[] = [] | ||
78 | filters: VideoFilters | ||
79 | syndicationItems: Syndication[] | ||
80 | |||
81 | onDataSubject = new Subject<any[]>() | ||
82 | hasDoneFirstQuery = false | ||
83 | |||
84 | userMiniature: User | ||
85 | |||
86 | private routeSub: Subscription | ||
87 | private userSub: Subscription | ||
88 | private resizeSub: Subscription | ||
89 | |||
90 | private pagination: ComponentPaginationLight = { | ||
91 | currentPage: 1, | ||
92 | itemsPerPage: 25 | ||
93 | } | ||
94 | |||
95 | private groupedDateLabels: { [id in GroupDate]: string } | ||
96 | private groupedDates: { [id: number]: GroupDate } = {} | ||
97 | |||
98 | private lastQueryLength: number | ||
99 | |||
100 | constructor ( | ||
101 | private notifier: Notifier, | ||
102 | private authService: AuthService, | ||
103 | private userService: UserService, | ||
104 | private route: ActivatedRoute, | ||
105 | private screenService: ScreenService, | ||
106 | private peertubeRouter: PeerTubeRouterService | ||
107 | ) { | ||
108 | |||
109 | } | ||
110 | |||
111 | ngOnInit () { | ||
112 | this.filters = new VideoFilters(this.defaultSort, this.defaultScope) | ||
113 | this.filters.load({ ...this.route.snapshot.queryParams, scope: this.defaultScope }) | ||
114 | |||
115 | this.groupedDateLabels = { | ||
116 | [GroupDate.UNKNOWN]: null, | ||
117 | [GroupDate.TODAY]: $localize`Today`, | ||
118 | [GroupDate.YESTERDAY]: $localize`Yesterday`, | ||
119 | [GroupDate.THIS_WEEK]: $localize`This week`, | ||
120 | [GroupDate.THIS_MONTH]: $localize`This month`, | ||
121 | [GroupDate.LAST_MONTH]: $localize`Last month`, | ||
122 | [GroupDate.OLDER]: $localize`Older` | ||
123 | } | ||
124 | |||
125 | this.resizeSub = fromEvent(window, 'resize') | ||
126 | .pipe(debounceTime(500)) | ||
127 | .subscribe(() => this.calcPageSizes()) | ||
128 | |||
129 | this.calcPageSizes() | ||
130 | |||
131 | this.userService.getAnonymousOrLoggedUser() | ||
132 | .subscribe(user => { | ||
133 | this.userMiniature = user | ||
134 | |||
135 | if (this.loadUserVideoPreferences) { | ||
136 | this.loadUserSettings(user) | ||
137 | } | ||
138 | |||
139 | this.scheduleOnFiltersChanged(false) | ||
140 | |||
141 | this.subscribeToAnonymousUpdate() | ||
142 | this.subscribeToSearchChange() | ||
143 | }) | ||
144 | |||
145 | // Display avatar in mobile view | ||
146 | if (this.screenService.isInMobileView()) { | ||
147 | this.displayOptions.avatar = true | ||
148 | } | ||
149 | } | ||
150 | |||
151 | ngOnDestroy () { | ||
152 | if (this.resizeSub) this.resizeSub.unsubscribe() | ||
153 | if (this.routeSub) this.routeSub.unsubscribe() | ||
154 | if (this.userSub) this.userSub.unsubscribe() | ||
155 | } | ||
156 | |||
157 | ngOnChanges (changes: SimpleChanges) { | ||
158 | if (!this.filters) return | ||
159 | |||
160 | let updated = false | ||
161 | |||
162 | if (changes['defaultScope']) { | ||
163 | updated = true | ||
164 | this.filters.setDefaultScope(this.defaultScope) | ||
165 | } | ||
166 | |||
167 | if (changes['defaultSort']) { | ||
168 | updated = true | ||
169 | this.filters.setDefaultSort(this.defaultSort) | ||
170 | } | ||
171 | |||
172 | if (!updated) return | ||
173 | |||
174 | const customizedByUser = this.hasBeenCustomizedByUser() | ||
175 | |||
176 | if (!customizedByUser) { | ||
177 | if (this.loadUserVideoPreferences) { | ||
178 | this.loadUserSettings(this.userMiniature) | ||
179 | } | ||
180 | |||
181 | this.filters.reset('scope') | ||
182 | this.filters.reset('sort') | ||
183 | } | ||
184 | |||
185 | this.scheduleOnFiltersChanged(customizedByUser) | ||
186 | } | ||
187 | |||
188 | videoById (_index: number, video: Video) { | ||
189 | return video.id | ||
190 | } | ||
191 | |||
192 | onNearOfBottom () { | ||
193 | if (this.disabled) return | ||
194 | |||
195 | // No more results | ||
196 | if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return | ||
197 | |||
198 | this.pagination.currentPage += 1 | ||
199 | |||
200 | this.loadMoreVideos() | ||
201 | } | ||
202 | |||
203 | loadMoreVideos (reset = false) { | ||
204 | this.getVideosObservableFunction(this.pagination, this.filters) | ||
205 | .subscribe({ | ||
206 | next: ({ data }) => { | ||
207 | this.hasDoneFirstQuery = true | ||
208 | this.lastQueryLength = data.length | ||
209 | |||
210 | if (reset) this.videos = [] | ||
211 | this.videos = this.videos.concat(data) | ||
212 | |||
213 | if (this.groupByDate) this.buildGroupedDateLabels() | ||
214 | |||
215 | this.onDataSubject.next(data) | ||
216 | }, | ||
217 | |||
218 | error: err => { | ||
219 | const message = $localize`Cannot load more videos. Try again later.` | ||
220 | |||
221 | console.error(message, { err }) | ||
222 | this.notifier.error(message) | ||
223 | } | ||
224 | }) | ||
225 | } | ||
226 | |||
227 | reloadVideos () { | ||
228 | this.pagination.currentPage = 1 | ||
229 | this.loadMoreVideos(true) | ||
230 | } | ||
231 | |||
232 | removeVideoFromArray (video: Video) { | ||
233 | this.videos = this.videos.filter(v => v.id !== video.id) | ||
234 | } | ||
235 | |||
236 | buildGroupedDateLabels () { | ||
237 | let currentGroupedDate: GroupDate = GroupDate.UNKNOWN | ||
238 | |||
239 | const periods = [ | ||
240 | { | ||
241 | value: GroupDate.TODAY, | ||
242 | validator: (d: Date) => isToday(d) | ||
243 | }, | ||
244 | { | ||
245 | value: GroupDate.YESTERDAY, | ||
246 | validator: (d: Date) => isYesterday(d) | ||
247 | }, | ||
248 | { | ||
249 | value: GroupDate.THIS_WEEK, | ||
250 | validator: (d: Date) => isLastWeek(d) | ||
251 | }, | ||
252 | { | ||
253 | value: GroupDate.THIS_MONTH, | ||
254 | validator: (d: Date) => isThisMonth(d) | ||
255 | }, | ||
256 | { | ||
257 | value: GroupDate.LAST_MONTH, | ||
258 | validator: (d: Date) => isLastMonth(d) | ||
259 | }, | ||
260 | { | ||
261 | value: GroupDate.OLDER, | ||
262 | validator: () => true | ||
263 | } | ||
264 | ] | ||
265 | |||
266 | for (const video of this.videos) { | ||
267 | const publishedDate = video.publishedAt | ||
268 | |||
269 | for (let i = 0; i < periods.length; i++) { | ||
270 | const period = periods[i] | ||
271 | |||
272 | if (currentGroupedDate <= period.value && period.validator(publishedDate)) { | ||
273 | |||
274 | if (currentGroupedDate !== period.value) { | ||
275 | currentGroupedDate = period.value | ||
276 | this.groupedDates[video.id] = currentGroupedDate | ||
277 | } | ||
278 | |||
279 | break | ||
280 | } | ||
281 | } | ||
282 | } | ||
283 | } | ||
284 | |||
285 | getCurrentGroupedDateLabel (video: Video) { | ||
286 | if (this.groupByDate === false) return undefined | ||
287 | |||
288 | return this.groupedDateLabels[this.groupedDates[video.id]] | ||
289 | } | ||
290 | |||
291 | scheduleOnFiltersChanged (customizedByUser: boolean) { | ||
292 | // We'll reload videos, but avoid weird UI effect | ||
293 | this.videos = [] | ||
294 | |||
295 | setTimeout(() => this.onFiltersChanged(customizedByUser)) | ||
296 | } | ||
297 | |||
298 | onFiltersChanged (customizedByUser: boolean) { | ||
299 | logger('Running on filters changed') | ||
300 | |||
301 | this.updateUrl(customizedByUser) | ||
302 | |||
303 | this.filters.triggerChange() | ||
304 | |||
305 | this.reloadSyndicationItems() | ||
306 | this.reloadVideos() | ||
307 | } | ||
308 | |||
309 | protected enableAllFilterIfPossible () { | ||
310 | if (!this.authService.isLoggedIn()) return | ||
311 | |||
312 | this.authService.userInformationLoaded | ||
313 | .subscribe(() => { | ||
314 | const user = this.authService.getUser() | ||
315 | this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS) | ||
316 | }) | ||
317 | } | ||
318 | |||
319 | private calcPageSizes () { | ||
320 | if (this.screenService.isInMobileView()) { | ||
321 | this.pagination.itemsPerPage = 5 | ||
322 | } | ||
323 | } | ||
324 | |||
325 | private loadUserSettings (user: User) { | ||
326 | this.filters.setNSFWPolicy(user.nsfwPolicy) | ||
327 | |||
328 | // Don't reset language filter if we don't want to refresh the component | ||
329 | if (!this.hasBeenCustomizedByUser()) { | ||
330 | this.filters.load({ languageOneOf: user.videoLanguages }) | ||
331 | } | ||
332 | } | ||
333 | |||
334 | private reloadSyndicationItems () { | ||
335 | Promise.resolve(this.getSyndicationItemsFunction(this.filters)) | ||
336 | .then(items => { | ||
337 | if (!items || items.length === 0) this.syndicationItems = undefined | ||
338 | else this.syndicationItems = items | ||
339 | }) | ||
340 | .catch(err => console.error('Cannot get syndication items.', err)) | ||
341 | } | ||
342 | |||
343 | private updateUrl (customizedByUser: boolean) { | ||
344 | const baseQuery = this.filters.toUrlObject() | ||
345 | |||
346 | // Set or reset customized by user query param | ||
347 | const queryParams = customizedByUser || this.hasBeenCustomizedByUser() | ||
348 | ? { ...baseQuery, c: customizedByUser } | ||
349 | : baseQuery | ||
350 | |||
351 | logger('Will inject %O in URL query', queryParams) | ||
352 | |||
353 | const baseRoute = this.baseRouteBuilderFunction | ||
354 | ? this.baseRouteBuilderFunction(this.filters) | ||
355 | : [] | ||
356 | |||
357 | const pathname = window.location.pathname | ||
358 | |||
359 | const baseRouteChanged = baseRoute.length !== 0 && | ||
360 | pathname !== '/' && // Exclude special '/' case, we'll be redirected without component change | ||
361 | baseRoute.length !== 0 && pathname !== baseRoute.join('/') | ||
362 | |||
363 | if (baseRouteChanged || Object.keys(baseQuery).length !== 0 || customizedByUser) { | ||
364 | this.peertubeRouter.silentNavigate(baseRoute, queryParams) | ||
365 | } | ||
366 | |||
367 | this.filtersChanged.emit(this.filters) | ||
368 | } | ||
369 | |||
370 | private hasBeenCustomizedByUser () { | ||
371 | return this.route.snapshot.queryParams['c'] === 'true' | ||
372 | } | ||
373 | |||
374 | private subscribeToAnonymousUpdate () { | ||
375 | this.userSub = this.userService.listenAnonymousUpdate() | ||
376 | .pipe(switchMap(() => this.userService.getAnonymousOrLoggedUser())) | ||
377 | .subscribe(user => { | ||
378 | if (this.loadUserVideoPreferences) { | ||
379 | this.loadUserSettings(user) | ||
380 | } | ||
381 | |||
382 | if (this.hasDoneFirstQuery) { | ||
383 | this.reloadVideos() | ||
384 | } | ||
385 | }) | ||
386 | } | ||
387 | |||
388 | private subscribeToSearchChange () { | ||
389 | this.routeSub = this.route.queryParams.subscribe(param => { | ||
390 | if (!param['search']) return | ||
391 | |||
392 | this.filters.load({ search: param['search'] }) | ||
393 | this.onFiltersChanged(true) | ||
394 | }) | ||
395 | } | ||
396 | } | ||
diff --git a/client/src/app/shared/shared-video-miniature/videos-selection.component.html b/client/src/app/shared/shared-video-miniature/videos-selection.component.html index 4ee90ce7f..f2af874dd 100644 --- a/client/src/app/shared/shared-video-miniature/videos-selection.component.html +++ b/client/src/app/shared/shared-video-miniature/videos-selection.component.html | |||
@@ -1,6 +1,9 @@ | |||
1 | <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">{{ noResultMessage }}</div> | 1 | <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">{{ noResultMessage }}</div> |
2 | 2 | ||
3 | <div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" class="videos"> | 3 | <div |
4 | class="videos" | ||
5 | myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" [setAngularState]="true" | ||
6 | > | ||
4 | <div class="video" *ngFor="let video of videos; let i = index; trackBy: videoById"> | 7 | <div class="video" *ngFor="let video of videos; let i = index; trackBy: videoById"> |
5 | 8 | ||
6 | <div class="checkbox-container" *ngIf="enableSelection"> | 9 | <div class="checkbox-container" *ngIf="enableSelection"> |
diff --git a/client/src/app/shared/shared-video-miniature/videos-selection.component.ts b/client/src/app/shared/shared-video-miniature/videos-selection.component.ts index 456b36926..cafaf6e85 100644 --- a/client/src/app/shared/shared-video-miniature/videos-selection.component.ts +++ b/client/src/app/shared/shared-video-miniature/videos-selection.component.ts | |||
@@ -1,22 +1,8 @@ | |||
1 | import { Observable } from 'rxjs' | 1 | import { Observable, Subject } from 'rxjs' |
2 | import { | 2 | import { AfterContentInit, Component, ContentChildren, EventEmitter, Input, Output, QueryList, TemplateRef } from '@angular/core' |
3 | AfterContentInit, | 3 | import { ComponentPagination, Notifier, User } from '@app/core' |
4 | Component, | ||
5 | ComponentFactoryResolver, | ||
6 | ContentChildren, | ||
7 | EventEmitter, | ||
8 | Input, | ||
9 | OnDestroy, | ||
10 | OnInit, | ||
11 | Output, | ||
12 | QueryList, | ||
13 | TemplateRef | ||
14 | } from '@angular/core' | ||
15 | import { ActivatedRoute, Router } from '@angular/router' | ||
16 | import { AuthService, ComponentPagination, LocalStorageService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core' | ||
17 | import { ResultList, VideoSortField } from '@shared/models' | 4 | import { ResultList, VideoSortField } from '@shared/models' |
18 | import { PeerTubeTemplateDirective, Video } from '../shared-main' | 5 | import { PeerTubeTemplateDirective, Video } from '../shared-main' |
19 | import { AbstractVideoList } from './abstract-video-list' | ||
20 | import { MiniatureDisplayOptions } from './video-miniature.component' | 6 | import { MiniatureDisplayOptions } from './video-miniature.component' |
21 | 7 | ||
22 | export type SelectionType = { [ id: number ]: boolean } | 8 | export type SelectionType = { [ id: number ]: boolean } |
@@ -26,14 +12,18 @@ export type SelectionType = { [ id: number ]: boolean } | |||
26 | templateUrl: './videos-selection.component.html', | 12 | templateUrl: './videos-selection.component.html', |
27 | styleUrls: [ './videos-selection.component.scss' ] | 13 | styleUrls: [ './videos-selection.component.scss' ] |
28 | }) | 14 | }) |
29 | export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit { | 15 | export class VideosSelectionComponent implements AfterContentInit { |
30 | @Input() user: User | 16 | @Input() user: User |
31 | @Input() pagination: ComponentPagination | 17 | @Input() pagination: ComponentPagination |
18 | |||
32 | @Input() titlePage: string | 19 | @Input() titlePage: string |
20 | |||
33 | @Input() miniatureDisplayOptions: MiniatureDisplayOptions | 21 | @Input() miniatureDisplayOptions: MiniatureDisplayOptions |
22 | |||
34 | @Input() noResultMessage = $localize`No results.` | 23 | @Input() noResultMessage = $localize`No results.` |
35 | @Input() enableSelection = true | 24 | @Input() enableSelection = true |
36 | @Input() loadOnInit = true | 25 | |
26 | @Input() disabled = false | ||
37 | 27 | ||
38 | @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>> | 28 | @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>> |
39 | 29 | ||
@@ -47,19 +37,18 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni | |||
47 | rowButtonsTemplate: TemplateRef<any> | 37 | rowButtonsTemplate: TemplateRef<any> |
48 | globalButtonsTemplate: TemplateRef<any> | 38 | globalButtonsTemplate: TemplateRef<any> |
49 | 39 | ||
40 | videos: Video[] = [] | ||
41 | sort: VideoSortField = '-publishedAt' | ||
42 | |||
43 | onDataSubject = new Subject<any[]>() | ||
44 | |||
45 | hasDoneFirstQuery = false | ||
46 | |||
47 | private lastQueryLength: number | ||
48 | |||
50 | constructor ( | 49 | constructor ( |
51 | protected router: Router, | 50 | private notifier: Notifier |
52 | protected route: ActivatedRoute, | 51 | ) { } |
53 | protected notifier: Notifier, | ||
54 | protected authService: AuthService, | ||
55 | protected userService: UserService, | ||
56 | protected screenService: ScreenService, | ||
57 | protected storageService: LocalStorageService, | ||
58 | protected serverService: ServerService, | ||
59 | protected cfr: ComponentFactoryResolver | ||
60 | ) { | ||
61 | super() | ||
62 | } | ||
63 | 52 | ||
64 | @Input() get selection () { | 53 | @Input() get selection () { |
65 | return this._selection | 54 | return this._selection |
@@ -79,10 +68,6 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni | |||
79 | this.videosModelChange.emit(this.videos) | 68 | this.videosModelChange.emit(this.videos) |
80 | } | 69 | } |
81 | 70 | ||
82 | ngOnInit () { | ||
83 | super.ngOnInit() | ||
84 | } | ||
85 | |||
86 | ngAfterContentInit () { | 71 | ngAfterContentInit () { |
87 | { | 72 | { |
88 | const t = this.templates.find(t => t.name === 'rowButtons') | 73 | const t = this.templates.find(t => t.name === 'rowButtons') |
@@ -93,10 +78,8 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni | |||
93 | const t = this.templates.find(t => t.name === 'globalButtons') | 78 | const t = this.templates.find(t => t.name === 'globalButtons') |
94 | if (t) this.globalButtonsTemplate = t.template | 79 | if (t) this.globalButtonsTemplate = t.template |
95 | } | 80 | } |
96 | } | ||
97 | 81 | ||
98 | ngOnDestroy () { | 82 | this.loadMoreVideos() |
99 | super.ngOnDestroy() | ||
100 | } | 83 | } |
101 | 84 | ||
102 | getVideosObservable (page: number) { | 85 | getVideosObservable (page: number) { |
@@ -111,11 +94,50 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni | |||
111 | return Object.keys(this._selection).some(k => this._selection[k] === true) | 94 | return Object.keys(this._selection).some(k => this._selection[k] === true) |
112 | } | 95 | } |
113 | 96 | ||
114 | generateSyndicationList () { | 97 | videoById (index: number, video: Video) { |
115 | throw new Error('Method not implemented.') | 98 | return video.id |
99 | } | ||
100 | |||
101 | onNearOfBottom () { | ||
102 | if (this.disabled) return | ||
103 | |||
104 | // No more results | ||
105 | if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return | ||
106 | |||
107 | this.pagination.currentPage += 1 | ||
108 | |||
109 | this.loadMoreVideos() | ||
110 | } | ||
111 | |||
112 | loadMoreVideos (reset = false) { | ||
113 | this.getVideosObservable(this.pagination.currentPage) | ||
114 | .subscribe({ | ||
115 | next: ({ data }) => { | ||
116 | this.hasDoneFirstQuery = true | ||
117 | this.lastQueryLength = data.length | ||
118 | |||
119 | if (reset) this.videos = [] | ||
120 | this.videos = this.videos.concat(data) | ||
121 | this.videosModel = this.videos | ||
122 | |||
123 | this.onDataSubject.next(data) | ||
124 | }, | ||
125 | |||
126 | error: err => { | ||
127 | const message = $localize`Cannot load more videos. Try again later.` | ||
128 | |||
129 | console.error(message, { err }) | ||
130 | this.notifier.error(message) | ||
131 | } | ||
132 | }) | ||
133 | } | ||
134 | |||
135 | reloadVideos () { | ||
136 | this.pagination.currentPage = 1 | ||
137 | this.loadMoreVideos(true) | ||
116 | } | 138 | } |
117 | 139 | ||
118 | protected onMoreVideos () { | 140 | removeVideoFromArray (video: Video) { |
119 | this.videosModel = this.videos | 141 | this.videos = this.videos.filter(v => v.id !== video.id) |
120 | } | 142 | } |
121 | } | 143 | } |