aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/shared')
-rw-r--r--client/src/app/shared/shared-forms/advanced-input-filter.component.ts3
-rw-r--r--client/src/app/shared/shared-forms/select/index.ts3
-rw-r--r--client/src/app/shared/shared-forms/select/select-categories.component.html8
-rw-r--r--client/src/app/shared/shared-forms/select/select-categories.component.ts71
-rw-r--r--client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts115
-rw-r--r--client/src/app/shared/shared-forms/select/select-checkbox.component.html2
-rw-r--r--client/src/app/shared/shared-forms/select/select-checkbox.component.ts7
-rw-r--r--client/src/app/shared/shared-forms/select/select-languages.component.html9
-rw-r--r--client/src/app/shared/shared-forms/select/select-languages.component.ts74
-rw-r--r--client/src/app/shared/shared-forms/shared-form.module.ts9
-rw-r--r--client/src/app/shared/shared-icons/global-icon.component.ts1
-rw-r--r--client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts22
-rw-r--r--client/src/app/shared/shared-main/feeds/feed.component.scss3
-rw-r--r--client/src/app/shared/shared-main/misc/simple-search-input.component.html23
-rw-r--r--client/src/app/shared/shared-main/misc/simple-search-input.component.scss13
-rw-r--r--client/src/app/shared/shared-main/misc/simple-search-input.component.ts32
-rw-r--r--client/src/app/shared/shared-main/users/user-notifications.component.html2
-rw-r--r--client/src/app/shared/shared-main/video/video.service.ts110
-rw-r--r--client/src/app/shared/shared-search/advanced-search.model.ts20
-rw-r--r--client/src/app/shared/shared-user-settings/user-video-settings.component.html7
-rw-r--r--client/src/app/shared/shared-user-settings/user-video-settings.component.scss2
-rw-r--r--client/src/app/shared/shared-user-settings/user-video-settings.component.ts93
-rw-r--r--client/src/app/shared/shared-video-miniature/abstract-video-list.ts404
-rw-r--r--client/src/app/shared/shared-video-miniature/index.ts5
-rw-r--r--client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts14
-rw-r--r--client/src/app/shared/shared-video-miniature/video-download.component.scss1
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.html131
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.scss139
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.ts119
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters.model.ts240
-rw-r--r--client/src/app/shared/shared-video-miniature/video-list-header.component.html5
-rw-r--r--client/src/app/shared/shared-video-miniature/video-list-header.component.ts22
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.html (renamed from client/src/app/shared/shared-video-miniature/abstract-video-list.html)41
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.scss (renamed from client/src/app/shared/shared-video-miniature/abstract-video-list.scss)71
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.ts396
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-selection.component.html5
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-selection.component.ts106
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})
19export class AdvancedInputFilterComponent implements OnInit, AfterViewInit { 19export 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 @@
1export * from './select-categories.component'
1export * from './select-channel.component' 2export * from './select-channel.component'
3export * from './select-checkbox-all.component'
2export * from './select-checkbox.component' 4export * from './select-checkbox.component'
3export * from './select-custom-value.component' 5export * from './select-custom-value.component'
6export * from './select-languages.component'
4export * from './select-options.component' 7export * from './select-options.component'
5export * from './select-tags.component' 8export * 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
2import { Component, forwardRef, OnInit } from '@angular/core'
3import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
4import { ServerService } from '@app/core'
5import { SelectOptionsItem } from '../../../../types/select-options-item.model'
6import { 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})
20export 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 @@
1import { Component, forwardRef, Input } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { Notifier } from '@app/core'
4import { SelectOptionsItem } from '../../../../types/select-options-item.model'
5import { 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})
30export 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'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { SelectOptionsItem } from '../../../../types/select-options-item.model' 3import { SelectOptionsItem } from '../../../../types/select-options-item.model'
4 4
5export type ItemSelectCheckboxValue = { id?: string | number, group?: string } | string 5export 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 @@
1import { Component, forwardRef, Input, OnInit } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { ServerService } from '@app/core'
4import { SelectOptionsItem } from '../../../../types/select-options-item.model'
5import { 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})
19export 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'
15import { PreviewUploadComponent } from './preview-upload.component' 15import { PreviewUploadComponent } from './preview-upload.component'
16import { ReactiveFileComponent } from './reactive-file.component' 16import { ReactiveFileComponent } from './reactive-file.component'
17import { 17import {
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 @@
1import { fromEvent, Observable, Subscription } from 'rxjs' 1import { fromEvent, Observable, Subscription } from 'rxjs'
2import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' 2import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
3import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' 3import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
4import { PeerTubeRouterService, RouterSetting } from '@app/core'
4 5
5@Directive({ 6@Directive({
6 selector: '[myInfiniteScroller]' 7 selector: '[myInfiniteScroller]'
7}) 8})
8export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked { 9export 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
13my-global-icon { 14my-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
28input { 21input {
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 @@
1import { Subject } from 'rxjs'
2import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
3import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
4import { 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'
5import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' 5import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core'
6import { objectToFormData } from '@app/helpers' 6import { objectToFormData } from '@app/helpers'
7import { 7import {
8 BooleanBothQuery,
8 FeedFormat, 9 FeedFormat,
9 NSFWPolicyType, 10 NSFWPolicyType,
10 ResultList, 11 ResultList,
@@ -28,19 +29,21 @@ import { VideoDetails } from './video-details.model'
28import { VideoEdit } from './video-edit.model' 29import { VideoEdit } from './video-edit.model'
29import { Video } from './video.model' 30import { Video } from './video.model'
30 31
31export interface VideosProvider { 32export 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()
43export class VideoService implements VideosProvider { 46export 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 @@
1import { intoArray } from '@app/helpers'
1import { 2import {
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
22my-select-checkbox { 22my-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 @@
1import { pick } from 'lodash-es' 1import { pick } from 'lodash-es'
2import { forkJoin, Subject, Subscription } from 'rxjs' 2import { Subject, Subscription } from 'rxjs'
3import { first } from 'rxjs/operators' 3import { first } from 'rxjs/operators'
4import { Component, Input, OnDestroy, OnInit } from '@angular/core' 4import { Component, Input, OnDestroy, OnInit } from '@angular/core'
5import { AuthService, Notifier, ServerService, User, UserService } from '@app/core' 5import { AuthService, Notifier, ServerService, User, UserService } from '@app/core'
6import { FormReactive, FormValidatorService, ItemSelectCheckboxValue } from '@app/shared/shared-forms' 6import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
7import { UserUpdateMe } from '@shared/models' 7import { UserUpdateMe } from '@shared/models'
8import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' 8import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
9import { 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 @@
1import { fromEvent, Observable, ReplaySubject, Subject, Subscription } from 'rxjs'
2import { debounceTime, switchMap, tap } from 'rxjs/operators'
3import {
4 AfterContentInit,
5 ComponentFactoryResolver,
6 Directive,
7 Injector,
8 OnDestroy,
9 OnInit,
10 Type,
11 ViewChild,
12 ViewContainerRef
13} from '@angular/core'
14import { ActivatedRoute, Params, Router } from '@angular/router'
15import {
16 AuthService,
17 ComponentPaginationLight,
18 LocalStorageService,
19 Notifier,
20 ScreenService,
21 ServerService,
22 User,
23 UserService
24} from '@app/core'
25import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
26import { GlobalIconName } from '@app/shared/shared-icons'
27import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils'
28import { HTMLServerConfig, UserRight, VideoFilter, VideoSortField } from '@shared/models'
29import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
30import { Syndication, Video } from '../shared-main'
31import { GenericHeaderComponent, VideoListHeaderComponent } from './video-list-header.component'
32import { MiniatureDisplayOptions } from './video-miniature.component'
33
34enum 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
46export 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 @@
1export * from './abstract-video-list'
2export * from './video-actions-dropdown.component' 1export * from './video-actions-dropdown.component'
3export * from './video-download.component' 2export * from './video-download.component'
3export * from './video-filters-header.component'
4export * from './video-filters.model'
4export * from './video-miniature.component' 5export * from './video-miniature.component'
6export * from './videos-list.component'
5export * from './videos-selection.component' 7export * from './videos-selection.component'
6export * from './video-list-header.component'
7export * from './shared-video-miniature.module' 8export * 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
2import { NgModule } from '@angular/core' 2import { NgModule } from '@angular/core'
3import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module'
3import { SharedFormModule } from '../shared-forms' 4import { SharedFormModule } from '../shared-forms'
4import { SharedGlobalIconModule } from '../shared-icons' 5import { SharedGlobalIconModule } from '../shared-icons'
5import { SharedMainModule } from '../shared-main/shared-main.module' 6import { SharedMainModule } from '../shared-main/shared-main.module'
6import { SharedModerationModule } from '../shared-moderation' 7import { SharedModerationModule } from '../shared-moderation'
7import { SharedVideoModule } from '../shared-video'
8import { SharedThumbnailModule } from '../shared-thumbnail' 8import { SharedThumbnailModule } from '../shared-thumbnail'
9import { SharedVideoModule } from '../shared-video'
9import { SharedVideoLiveModule } from '../shared-video-live' 10import { SharedVideoLiveModule } from '../shared-video-live'
10import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module' 11import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module'
11import { VideoActionsDropdownComponent } from './video-actions-dropdown.component' 12import { VideoActionsDropdownComponent } from './video-actions-dropdown.component'
12import { VideoDownloadComponent } from './video-download.component' 13import { VideoDownloadComponent } from './video-download.component'
14import { VideoFiltersHeaderComponent } from './video-filters-header.component'
13import { VideoMiniatureComponent } from './video-miniature.component' 15import { VideoMiniatureComponent } from './video-miniature.component'
16import { VideosListComponent } from './videos-list.component'
14import { VideosSelectionComponent } from './videos-selection.component' 17import { VideosSelectionComponent } from './videos-selection.component'
15import { VideoListHeaderComponent } from './video-list-header.component'
16import { 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
119my-select-languages,
120my-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 @@
1import * as debug from 'debug'
2import { Subscription } from 'rxjs'
3import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
4import { FormBuilder, FormGroup } from '@angular/forms'
5import { AuthService } from '@app/core'
6import { ServerService } from '@app/core/server/server.service'
7import { UserRight } from '@shared/models'
8import { NSFWPolicyType } from '@shared/models/videos'
9import { PeertubeModalService } from '../shared-main'
10import { VideoFilters } from './video-filters.model'
11
12const 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})
19export 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 @@
1import { intoArray, toBoolean } from '@app/helpers'
2import { AttributesOnly } from '@shared/core-utils'
3import { BooleanBothQuery, NSFWPolicyType, VideoFilter, VideoSortField } from '@shared/models'
4
5type VideoFiltersKeys = {
6 [ id in keyof AttributesOnly<VideoFilters> ]: any
7}
8
9export type VideoFilterScope = 'local' | 'federated'
10
11export 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 @@
1import { Component, Inject, ViewEncapsulation } from '@angular/core'
2
3export interface GenericHeaderData {
4 titlePage: string
5 titleTooltip?: string
6}
7
8export 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})
18export 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 @@
1import * as debug from 'debug'
2import { fromEvent, Observable, Subject, Subscription } from 'rxjs'
3import { debounceTime, switchMap } from 'rxjs/operators'
4import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'
5import { ActivatedRoute } from '@angular/router'
6import { AuthService, ComponentPaginationLight, Notifier, PeerTubeRouterService, ScreenService, User, UserService } from '@app/core'
7import { GlobalIconName } from '@app/shared/shared-icons'
8import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils'
9import { ResultList, UserRight, VideoSortField } from '@shared/models'
10import { Syndication, Video } from '../shared-main'
11import { VideoFilters, VideoFilterScope } from './video-filters.model'
12import { MiniatureDisplayOptions } from './video-miniature.component'
13
14const logger = debug('peertube:videos:VideosListComponent')
15
16export type HeaderAction = {
17 iconName: GlobalIconName
18 label: string
19 justIcon?: boolean
20 routerLink?: string
21 href?: string
22 click?: (e: Event) => void
23}
24
25enum 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})
40export 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 @@
1import { Observable } from 'rxjs' 1import { Observable, Subject } from 'rxjs'
2import { 2import { AfterContentInit, Component, ContentChildren, EventEmitter, Input, Output, QueryList, TemplateRef } from '@angular/core'
3 AfterContentInit, 3import { 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'
15import { ActivatedRoute, Router } from '@angular/router'
16import { AuthService, ComponentPagination, LocalStorageService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core'
17import { ResultList, VideoSortField } from '@shared/models' 4import { ResultList, VideoSortField } from '@shared/models'
18import { PeerTubeTemplateDirective, Video } from '../shared-main' 5import { PeerTubeTemplateDirective, Video } from '../shared-main'
19import { AbstractVideoList } from './abstract-video-list'
20import { MiniatureDisplayOptions } from './video-miniature.component' 6import { MiniatureDisplayOptions } from './video-miniature.component'
21 7
22export type SelectionType = { [ id: number ]: boolean } 8export 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})
29export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit { 15export 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}