aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2018-07-20 18:31:49 +0200
committerChocobozzz <me@florianbigard.com>2018-07-24 14:04:05 +0200
commit0b18f4aa80df8868bf34605423c7a298dffbb2aa (patch)
tree25299da5d94fc73e88b21e87aeb2c156999c6fcd
parentd525fc399a14a8b16eaad6d4c0bc0a9c4093c3c9 (diff)
downloadPeerTube-0b18f4aa80df8868bf34605423c7a298dffbb2aa.tar.gz
PeerTube-0b18f4aa80df8868bf34605423c7a298dffbb2aa.tar.zst
PeerTube-0b18f4aa80df8868bf34605423c7a298dffbb2aa.zip
Add advanced search in client
-rw-r--r--client/src/app/search/advanced-search.model.ts101
-rw-r--r--client/src/app/search/search-filters.component.html87
-rw-r--r--client/src/app/search/search-filters.component.scss40
-rw-r--r--client/src/app/search/search-filters.component.ts170
-rw-r--r--client/src/app/search/search.component.html30
-rw-r--r--client/src/app/search/search.component.scss46
-rw-r--r--client/src/app/search/search.component.ts29
-rw-r--r--client/src/app/search/search.module.ts9
-rw-r--r--client/src/app/search/search.service.ts17
-rw-r--r--client/src/assets/images/search/filter.svg17
-rw-r--r--client/tsconfig.json8
-rw-r--r--server/helpers/custom-validators/search.ts7
-rw-r--r--server/helpers/express-utils.ts6
-rw-r--r--server/initializers/database.ts2
-rw-r--r--server/middlewares/validators/search.ts5
-rw-r--r--server/models/video/video.ts17
-rw-r--r--server/tests/api/search/search-videos.ts8
-rw-r--r--server/tests/api/videos/video-nsfw.ts11
-rw-r--r--shared/models/search/index.ts1
-rw-r--r--shared/models/search/nsfw-query.model.ts1
-rw-r--r--shared/models/search/videos-search-query.model.ts4
21 files changed, 583 insertions, 33 deletions
diff --git a/client/src/app/search/advanced-search.model.ts b/client/src/app/search/advanced-search.model.ts
new file mode 100644
index 000000000..a0f333175
--- /dev/null
+++ b/client/src/app/search/advanced-search.model.ts
@@ -0,0 +1,101 @@
1import { NSFWQuery } from '../../../../shared/models/search'
2
3export class AdvancedSearch {
4 startDate: string // ISO 8601
5 endDate: string // ISO 8601
6
7 nsfw: NSFWQuery
8
9 categoryOneOf: string
10
11 licenceOneOf: string
12
13 languageOneOf: string
14
15 tagsOneOf: string
16 tagsAllOf: string
17
18 durationMin: number // seconds
19 durationMax: number // seconds
20
21 constructor (options?: {
22 startDate?: string
23 endDate?: string
24 nsfw?: NSFWQuery
25 categoryOneOf?: string
26 licenceOneOf?: string
27 languageOneOf?: string
28 tagsOneOf?: string
29 tagsAllOf?: string
30 durationMin?: string
31 durationMax?: string
32 }) {
33 if (!options) return
34
35 this.startDate = options.startDate
36 this.endDate = options.endDate
37 this.nsfw = options.nsfw
38 this.categoryOneOf = options.categoryOneOf
39 this.licenceOneOf = options.licenceOneOf
40 this.languageOneOf = options.languageOneOf
41 this.tagsOneOf = options.tagsOneOf
42 this.tagsAllOf = options.tagsAllOf
43 this.durationMin = parseInt(options.durationMin, 10)
44 this.durationMax = parseInt(options.durationMax, 10)
45
46 if (isNaN(this.durationMin)) this.durationMin = undefined
47 if (isNaN(this.durationMax)) this.durationMax = undefined
48 }
49
50 containsValues () {
51 const obj = this.toUrlObject()
52 for (const k of Object.keys(obj)) {
53 if (obj[k] !== undefined) return true
54 }
55
56 return false
57 }
58
59 reset () {
60 this.startDate = undefined
61 this.endDate = undefined
62 this.nsfw = undefined
63 this.categoryOneOf = undefined
64 this.licenceOneOf = undefined
65 this.languageOneOf = undefined
66 this.tagsOneOf = undefined
67 this.tagsAllOf = undefined
68 this.durationMin = undefined
69 this.durationMax = undefined
70 }
71
72 toUrlObject () {
73 return {
74 startDate: this.startDate,
75 endDate: this.endDate,
76 nsfw: this.nsfw,
77 categoryOneOf: this.categoryOneOf,
78 licenceOneOf: this.licenceOneOf,
79 languageOneOf: this.languageOneOf,
80 tagsOneOf: this.tagsOneOf,
81 tagsAllOf: this.tagsAllOf,
82 durationMin: this.durationMin,
83 durationMax: this.durationMax
84 }
85 }
86
87 toAPIObject () {
88 return {
89 startDate: this.startDate,
90 endDate: this.endDate,
91 nsfw: this.nsfw,
92 categoryOneOf: this.categoryOneOf ? this.categoryOneOf.split(',') : undefined,
93 licenceOneOf: this.licenceOneOf ? this.licenceOneOf.split(',') : undefined,
94 languageOneOf: this.languageOneOf ? this.languageOneOf.split(',') : undefined,
95 tagsOneOf: this.tagsOneOf ? this.tagsOneOf.split(',') : undefined,
96 tagsAllOf: this.tagsAllOf ? this.tagsAllOf.split(',') : undefined,
97 durationMin: this.durationMin,
98 durationMax: this.durationMax
99 }
100 }
101}
diff --git a/client/src/app/search/search-filters.component.html b/client/src/app/search/search-filters.component.html
new file mode 100644
index 000000000..f8b3675e5
--- /dev/null
+++ b/client/src/app/search/search-filters.component.html
@@ -0,0 +1,87 @@
1<form role="form" (ngSubmit)="formUpdated()">
2
3 <div class="row">
4 <div class="col-lg-4 col-md-6 col-xs-12">
5 <div class="form-group">
6 <div i18n class="radio-label">Published date</div>
7
8 <div class="peertube-radio-container" *ngFor="let date of publishedDateRanges">
9 <input type="radio" name="publishedDateRange" [id]="date.id" [value]="date.id" [(ngModel)]="publishedDateRange">
10 <label [for]="date.id" class="radio">{{ date.label }}</label>
11 </div>
12 </div>
13
14 <div class="form-group">
15 <div i18n class="radio-label">Duration</div>
16
17 <div class="peertube-radio-container" *ngFor="let duration of durationRanges">
18 <input type="radio" name="durationRange" [id]="duration.id" [value]="duration.id" [(ngModel)]="durationRange">
19 <label [for]="duration.id" class="radio">{{ duration.label }}</label>
20 </div>
21 </div>
22
23 <div class="form-group">
24 <div i18n class="radio-label">Display sensitive content</div>
25
26 <div class="peertube-radio-container">
27 <input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" [(ngModel)]="advancedSearch.nsfw">
28 <label i18n for="sensitiveContentYes" class="radio">Yes</label>
29 </div>
30
31 <div class="peertube-radio-container">
32 <input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" [(ngModel)]="advancedSearch.nsfw">
33 <label i18n for="sensitiveContentNo" class="radio">No</label>
34 </div>
35 </div>
36
37 </div>
38
39 <div class="col-lg-4 col-md-6 col-xs-12">
40 <div class="form-group">
41 <label i18n for="category">Category</label>
42 <div class="peertube-select-container">
43 <select id="category" name="category" [(ngModel)]="advancedSearch.categoryOneOf">
44 <option></option>
45 <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option>
46 </select>
47 </div>
48 </div>
49
50 <div class="form-group">
51 <label i18n for="licence">Licence</label>
52 <div class="peertube-select-container">
53 <select id="licence" name="licence" [(ngModel)]="advancedSearch.licenceOneOf">
54 <option></option>
55 <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option>
56 </select>
57 </div>
58 </div>
59
60 <div class="form-group">
61 <label i18n for="language">Language</label>
62 <div class="peertube-select-container">
63 <select id="language" name="language" [(ngModel)]="advancedSearch.languageOneOf">
64 <option></option>
65 <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option>
66 </select>
67 </div>
68 </div>
69 </div>
70
71 <div class="col-lg-4 col-md-6 col-xs-12">
72 <div class="form-group">
73 <label i18n for="tagsAllOf">All of these tags</label>
74 <input type="text" name="tagsAllOf" id="tagsAllOf" [(ngModel)]="advancedSearch.tagsAllOf" />
75 </div>
76
77 <div class="form-group">
78 <label i18n for="tagsOneOf">One of these tags</label>
79 <input type="text" name="tagsOneOf" id="tagsOneOf" [(ngModel)]="advancedSearch.tagsOneOf" />
80 </div>
81 </div>
82 </div>
83
84 <div class="submit-button">
85 <input type="submit" i18n-value value="Filter">
86 </div>
87</form> \ No newline at end of file
diff --git a/client/src/app/search/search-filters.component.scss b/client/src/app/search/search-filters.component.scss
new file mode 100644
index 000000000..cfc48fbef
--- /dev/null
+++ b/client/src/app/search/search-filters.component.scss
@@ -0,0 +1,40 @@
1@import '_variables';
2@import '_mixins';
3
4form {
5 margin-top: 40px;
6}
7
8.radio-label {
9 font-size: 15px;
10 font-weight: $font-bold;
11}
12
13.peertube-radio-container {
14 @include peertube-radio-container;
15
16 display: inline-block;
17 margin-right: 30px;
18}
19
20.peertube-select-container {
21 @include peertube-select-container(auto);
22}
23
24.form-group {
25 margin-bottom: 25px;
26}
27
28input[type=text] {
29 @include peertube-input-text(100%);
30 display: block;
31}
32
33input[type=submit] {
34 @include peertube-button-link;
35 @include orange-button;
36}
37
38.submit-button {
39 text-align: right;
40} \ No newline at end of file
diff --git a/client/src/app/search/search-filters.component.ts b/client/src/app/search/search-filters.component.ts
new file mode 100644
index 000000000..4219f99a9
--- /dev/null
+++ b/client/src/app/search/search-filters.component.ts
@@ -0,0 +1,170 @@
1import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
2import { ActivatedRoute } from '@angular/router'
3import { RedirectService, ServerService } from '@app/core'
4import { NotificationsService } from 'angular2-notifications'
5import { SearchService } from '@app/search/search.service'
6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { MetaService } from '@ngx-meta/core'
8import { AdvancedSearch } from '@app/search/advanced-search.model'
9import { VideoConstant } from '../../../../shared'
10
11@Component({
12 selector: 'my-search-filters',
13 styleUrls: [ './search-filters.component.scss' ],
14 templateUrl: './search-filters.component.html'
15})
16export class SearchFiltersComponent implements OnInit {
17 @Input() advancedSearch: AdvancedSearch = new AdvancedSearch()
18
19 @Output() filtered = new EventEmitter<AdvancedSearch>()
20
21 videoCategories: VideoConstant<string>[] = []
22 videoLicences: VideoConstant<string>[] = []
23 videoLanguages: VideoConstant<string>[] = []
24
25 publishedDateRanges: { id: string, label: string }[] = []
26 durationRanges: { id: string, label: string }[] = []
27
28 publishedDateRange: string
29 durationRange: string
30
31 constructor (
32 private i18n: I18n,
33 private route: ActivatedRoute,
34 private metaService: MetaService,
35 private redirectService: RedirectService,
36 private notificationsService: NotificationsService,
37 private searchService: SearchService,
38 private serverService: ServerService
39 ) {
40 this.publishedDateRanges = [
41 {
42 id: 'today',
43 label: this.i18n('Today')
44 },
45 {
46 id: 'last_7days',
47 label: this.i18n('Last 7 days')
48 },
49 {
50 id: 'last_30days',
51 label: this.i18n('Last 30 days')
52 },
53 {
54 id: 'last_365days',
55 label: this.i18n('Last 365 days')
56 }
57 ]
58
59 this.durationRanges = [
60 {
61 id: 'short',
62 label: this.i18n('Short (< 4 minutes)')
63 },
64 {
65 id: 'long',
66 label: this.i18n('Long (> 10 minutes)')
67 },
68 {
69 id: 'medium',
70 label: this.i18n('Medium (4-10 minutes)')
71 }
72 ]
73 }
74
75 ngOnInit () {
76 this.videoCategories = this.serverService.getVideoCategories()
77 this.videoLicences = this.serverService.getVideoLicences()
78 this.videoLanguages = this.serverService.getVideoLanguages()
79
80 this.loadFromDurationRange()
81 this.loadFromPublishedRange()
82 }
83
84 formUpdated () {
85 this.updateModelFromDurationRange()
86 this.updateModelFromPublishedRange()
87
88 this.filtered.emit(this.advancedSearch)
89 }
90
91 private loadFromDurationRange () {
92 if (this.advancedSearch.durationMin || this.advancedSearch.durationMax) {
93 const fourMinutes = 60 * 4
94 const tenMinutes = 60 * 10
95
96 if (this.advancedSearch.durationMin === fourMinutes && this.advancedSearch.durationMax === tenMinutes) {
97 this.durationRange = 'medium'
98 } else if (this.advancedSearch.durationMax === fourMinutes) {
99 this.durationRange = 'short'
100 } else if (this.advancedSearch.durationMin === tenMinutes) {
101 this.durationRange = 'long'
102 }
103 }
104 }
105
106 private loadFromPublishedRange () {
107 if (this.advancedSearch.startDate) {
108 const date = new Date(this.advancedSearch.startDate)
109 const now = new Date()
110
111 const diff = Math.abs(date.getTime() - now.getTime())
112
113 const dayMS = 1000 * 3600 * 24
114 const numberOfDays = diff / dayMS
115
116 if (numberOfDays >= 365) this.publishedDateRange = 'last_365days'
117 else if (numberOfDays >= 30) this.publishedDateRange = 'last_30days'
118 else if (numberOfDays >= 7) this.publishedDateRange = 'last_7days'
119 else if (numberOfDays >= 0) this.publishedDateRange = 'today'
120 }
121 }
122
123 private updateModelFromDurationRange () {
124 if (!this.durationRange) return
125
126 const fourMinutes = 60 * 4
127 const tenMinutes = 60 * 10
128
129 switch (this.durationRange) {
130 case 'short':
131 this.advancedSearch.durationMin = undefined
132 this.advancedSearch.durationMax = fourMinutes
133 break
134
135 case 'medium':
136 this.advancedSearch.durationMin = fourMinutes
137 this.advancedSearch.durationMax = tenMinutes
138 break
139
140 case 'long':
141 this.advancedSearch.durationMin = tenMinutes
142 this.advancedSearch.durationMax = undefined
143 break
144 }
145 }
146
147 private updateModelFromPublishedRange () {
148 if (!this.publishedDateRange) return
149
150 // today
151 const date = new Date()
152 date.setHours(0, 0, 0, 0)
153
154 switch (this.publishedDateRange) {
155 case 'last_7days':
156 date.setDate(date.getDate() - 7)
157 break
158
159 case 'last_30days':
160 date.setDate(date.getDate() - 30)
161 break
162
163 case 'last_365days':
164 date.setDate(date.getDate() - 365)
165 break
166 }
167
168 this.advancedSearch.startDate = date.toISOString()
169 }
170}
diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html
index b8c4d7dc5..3a63dbcec 100644
--- a/client/src/app/search/search.component.html
+++ b/client/src/app/search/search.component.html
@@ -1,10 +1,28 @@
1<div i18n *ngIf="pagination.totalItems === 0" class="no-result">
2 No results found
3</div>
4
5<div myInfiniteScroller [autoLoading]="true" (nearOfBottom)="onNearOfBottom()" class="search-result"> 1<div myInfiniteScroller [autoLoading]="true" (nearOfBottom)="onNearOfBottom()" class="search-result">
6 <div i18n *ngIf="pagination.totalItems" class="results-counter"> 2 <div i18n class="results-header">
7 {{ pagination.totalItems | myNumberFormatter }} results for <span class="search-value">{{ currentSearch }}</span> 3 <div class="first-line">
4 <div class="results-counter">
5 <ng-container *ngIf="pagination.totalItems">
6 {{ pagination.totalItems | myNumberFormatter }} results for <span class="search-value">{{ currentSearch }}</span>
7 </ng-container>
8 </div>
9
10 <div
11 class="results-filter-button" (click)="isSearchFilterCollapsed = !isSearchFilterCollapsed" role="button"
12 [attr.aria-expanded]="isSearchFilterCollapsed" aria-controls="collapseBasic"
13 >
14 <span class="icon icon-filter"></span>
15 <ng-container i18n>Filters</ng-container>
16 </div>
17 </div>
18
19 <div class="results-filter" [collapse]="isSearchFilterCollapsed">
20 <my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered($event)"></my-search-filters>
21 </div>
22 </div>
23
24 <div i18n *ngIf="pagination.totalItems === 0" class="no-result">
25 No results found
8 </div> 26 </div>
9 27
10 <div *ngFor="let video of videos" class="entry video"> 28 <div *ngFor="let video of videos" class="entry video">
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss
index 06e3c9542..f70d4bf87 100644
--- a/client/src/app/search/search.component.scss
+++ b/client/src/app/search/search.component.scss
@@ -2,7 +2,7 @@
2@import '_mixins'; 2@import '_mixins';
3 3
4.no-result { 4.no-result {
5 height: 70vh; 5 height: 40vh;
6 display: flex; 6 display: flex;
7 align-items: center; 7 align-items: center;
8 justify-content: center; 8 justify-content: center;
@@ -11,17 +11,49 @@
11} 11}
12 12
13.search-result { 13.search-result {
14 margin-left: 40px; 14 margin: 40px;
15 margin-top: 40px;
16 15
17 .results-counter { 16 .results-header {
18 font-size: 15px; 17 font-size: 16px;
19 padding-bottom: 20px; 18 padding-bottom: 20px;
20 margin-bottom: 30px; 19 margin-bottom: 30px;
21 border-bottom: 1px solid #DADADA; 20 border-bottom: 1px solid #DADADA;
22 21
23 .search-value { 22 .first-line {
24 font-weight: $font-semibold; 23 display: flex;
24 flex-direction: row;
25
26 .results-counter {
27 flex-grow: 1;
28
29 .search-value {
30 font-weight: $font-semibold;
31 }
32 }
33
34 .results-filter-button {
35
36 .icon.icon-filter {
37 @include icon(20px);
38
39 position: relative;
40 top: -1px;
41 margin-right: 5px;
42 background-image: url('../../assets/images/search/filter.svg');
43 }
44 }
45 }
46
47 .results-filter {
48 // Animation when we show/hide the filters
49 transition: max-height 0.3s;
50 display: block !important;
51 overflow: hidden !important;
52 max-height: 0;
53
54 &.show {
55 max-height: 800px;
56 }
25 } 57 }
26 } 58 }
27 59
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts
index be1cb3689..09028fec5 100644
--- a/client/src/app/search/search.component.ts
+++ b/client/src/app/search/search.component.ts
@@ -1,5 +1,5 @@
1import { Component, OnDestroy, OnInit } from '@angular/core' 1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { RedirectService } from '@app/core' 3import { RedirectService } from '@app/core'
4import { NotificationsService } from 'angular2-notifications' 4import { NotificationsService } from 'angular2-notifications'
5import { Subscription } from 'rxjs' 5import { Subscription } from 'rxjs'
@@ -8,6 +8,7 @@ import { ComponentPagination } from '@app/shared/rest/component-pagination.model
8import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
9import { Video } from '../../../../shared' 9import { Video } from '../../../../shared'
10import { MetaService } from '@ngx-meta/core' 10import { MetaService } from '@ngx-meta/core'
11import { AdvancedSearch } from '@app/search/advanced-search.model'
11 12
12@Component({ 13@Component({
13 selector: 'my-search', 14 selector: 'my-search',
@@ -21,6 +22,8 @@ export class SearchComponent implements OnInit, OnDestroy {
21 itemsPerPage: 10, // It's per object type (so 10 videos, 10 video channels etc) 22 itemsPerPage: 10, // It's per object type (so 10 videos, 10 video channels etc)
22 totalItems: null 23 totalItems: null
23 } 24 }
25 advancedSearch: AdvancedSearch = new AdvancedSearch()
26 isSearchFilterCollapsed = true
24 27
25 private subActivatedRoute: Subscription 28 private subActivatedRoute: Subscription
26 private currentSearch: string 29 private currentSearch: string
@@ -28,6 +31,7 @@ export class SearchComponent implements OnInit, OnDestroy {
28 constructor ( 31 constructor (
29 private i18n: I18n, 32 private i18n: I18n,
30 private route: ActivatedRoute, 33 private route: ActivatedRoute,
34 private router: Router,
31 private metaService: MetaService, 35 private metaService: MetaService,
32 private redirectService: RedirectService, 36 private redirectService: RedirectService,
33 private notificationsService: NotificationsService, 37 private notificationsService: NotificationsService,
@@ -35,6 +39,9 @@ export class SearchComponent implements OnInit, OnDestroy {
35 ) { } 39 ) { }
36 40
37 ngOnInit () { 41 ngOnInit () {
42 this.advancedSearch = new AdvancedSearch(this.route.snapshot.queryParams)
43 if (this.advancedSearch.containsValues()) this.isSearchFilterCollapsed = false
44
38 this.subActivatedRoute = this.route.queryParams.subscribe( 45 this.subActivatedRoute = this.route.queryParams.subscribe(
39 queryParams => { 46 queryParams => {
40 const querySearch = queryParams['search'] 47 const querySearch = queryParams['search']
@@ -42,6 +49,9 @@ export class SearchComponent implements OnInit, OnDestroy {
42 if (!querySearch) return this.redirectService.redirectToHomepage() 49 if (!querySearch) return this.redirectService.redirectToHomepage()
43 if (querySearch === this.currentSearch) return 50 if (querySearch === this.currentSearch) return
44 51
52 // Search updated, reset filters
53 if (this.currentSearch) this.advancedSearch.reset()
54
45 this.currentSearch = querySearch 55 this.currentSearch = querySearch
46 this.updateTitle() 56 this.updateTitle()
47 57
@@ -57,7 +67,7 @@ export class SearchComponent implements OnInit, OnDestroy {
57 } 67 }
58 68
59 search () { 69 search () {
60 return this.searchService.searchVideos(this.currentSearch, this.pagination) 70 return this.searchService.searchVideos(this.currentSearch, this.pagination, this.advancedSearch)
61 .subscribe( 71 .subscribe(
62 ({ videos, totalVideos }) => { 72 ({ videos, totalVideos }) => {
63 this.videos = this.videos.concat(videos) 73 this.videos = this.videos.concat(videos)
@@ -78,6 +88,14 @@ export class SearchComponent implements OnInit, OnDestroy {
78 this.search() 88 this.search()
79 } 89 }
80 90
91 onFiltered () {
92 this.updateUrlFromAdvancedSearch()
93 // Hide the filters
94 this.isSearchFilterCollapsed = true
95
96 this.reload()
97 }
98
81 private reload () { 99 private reload () {
82 this.pagination.currentPage = 1 100 this.pagination.currentPage = 1
83 this.pagination.totalItems = null 101 this.pagination.totalItems = null
@@ -90,4 +108,11 @@ export class SearchComponent implements OnInit, OnDestroy {
90 private updateTitle () { 108 private updateTitle () {
91 this.metaService.setTitle(this.i18n('Search') + ' ' + this.currentSearch) 109 this.metaService.setTitle(this.i18n('Search') + ' ' + this.currentSearch)
92 } 110 }
111
112 private updateUrlFromAdvancedSearch () {
113 this.router.navigate([], {
114 relativeTo: this.route,
115 queryParams: Object.assign({}, this.advancedSearch.toUrlObject(), { search: this.currentSearch })
116 })
117 }
93} 118}
diff --git a/client/src/app/search/search.module.ts b/client/src/app/search/search.module.ts
index c6ec74d20..488046cf1 100644
--- a/client/src/app/search/search.module.ts
+++ b/client/src/app/search/search.module.ts
@@ -3,15 +3,20 @@ import { SharedModule } from '../shared'
3import { SearchComponent } from '@app/search/search.component' 3import { SearchComponent } from '@app/search/search.component'
4import { SearchService } from '@app/search/search.service' 4import { SearchService } from '@app/search/search.service'
5import { SearchRoutingModule } from '@app/search/search-routing.module' 5import { SearchRoutingModule } from '@app/search/search-routing.module'
6import { SearchFiltersComponent } from '@app/search/search-filters.component'
7import { CollapseModule } from 'ngx-bootstrap/collapse'
6 8
7@NgModule({ 9@NgModule({
8 imports: [ 10 imports: [
9 SearchRoutingModule, 11 SearchRoutingModule,
10 SharedModule 12 SharedModule,
13
14 CollapseModule.forRoot()
11 ], 15 ],
12 16
13 declarations: [ 17 declarations: [
14 SearchComponent 18 SearchComponent,
19 SearchFiltersComponent
15 ], 20 ],
16 21
17 exports: [ 22 exports: [
diff --git a/client/src/app/search/search.service.ts b/client/src/app/search/search.service.ts
index 02d5f5915..c6106afd6 100644
--- a/client/src/app/search/search.service.ts
+++ b/client/src/app/search/search.service.ts
@@ -8,6 +8,7 @@ import { RestExtractor, RestService } from '@app/shared'
8import { environment } from 'environments/environment' 8import { environment } from 'environments/environment'
9import { ResultList, Video } from '../../../../shared' 9import { ResultList, Video } from '../../../../shared'
10import { Video as VideoServerModel } from '@app/shared/video/video.model' 10import { Video as VideoServerModel } from '@app/shared/video/video.model'
11import { AdvancedSearch } from '@app/search/advanced-search.model'
11 12
12export type SearchResult = { 13export type SearchResult = {
13 videosResult: { totalVideos: number, videos: Video[] } 14 videosResult: { totalVideos: number, videos: Video[] }
@@ -26,7 +27,8 @@ export class SearchService {
26 27
27 searchVideos ( 28 searchVideos (
28 search: string, 29 search: string,
29 componentPagination: ComponentPagination 30 componentPagination: ComponentPagination,
31 advancedSearch: AdvancedSearch
30 ): Observable<{ videos: Video[], totalVideos: number }> { 32 ): Observable<{ videos: Video[], totalVideos: number }> {
31 const url = SearchService.BASE_SEARCH_URL + 'videos' 33 const url = SearchService.BASE_SEARCH_URL + 'videos'
32 34
@@ -36,6 +38,19 @@ export class SearchService {
36 params = this.restService.addRestGetParams(params, pagination) 38 params = this.restService.addRestGetParams(params, pagination)
37 params = params.append('search', search) 39 params = params.append('search', search)
38 40
41 const advancedSearchObject = advancedSearch.toAPIObject()
42
43 for (const name of Object.keys(advancedSearchObject)) {
44 const value = advancedSearchObject[name]
45 if (!value) continue
46
47 if (Array.isArray(value)) {
48 for (const v of value) params = params.append(name, v)
49 } else {
50 params = params.append(name, value)
51 }
52 }
53
39 return this.authHttp 54 return this.authHttp
40 .get<ResultList<VideoServerModel>>(url, { params }) 55 .get<ResultList<VideoServerModel>>(url, { params })
41 .pipe( 56 .pipe(
diff --git a/client/src/assets/images/search/filter.svg b/client/src/assets/images/search/filter.svg
new file mode 100644
index 000000000..218d6dee7
--- /dev/null
+++ b/client/src/assets/images/search/filter.svg
@@ -0,0 +1,17 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3 <!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
4 <title>filter-ios</title>
5 <desc>Created with Sketch.</desc>
6 <defs></defs>
7 <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
8 <g id="Artboard-4" transform="translate(-796.000000, -291.000000)">
9 <g id="98" transform="translate(796.000000, 291.000000)">
10 <circle id="Oval-23" stroke="#333333" stroke-width="2" cx="12" cy="12" r="10"></circle>
11 <rect id="Rectangle-44" fill="#333333" x="6" y="8" width="12" height="2" rx="1"></rect>
12 <rect id="Rectangle-44" fill="#333333" x="8" y="12" width="8" height="2" rx="1"></rect>
13 <rect id="Rectangle-44" fill="#333333" x="10" y="16" width="4" height="2" rx="1"></rect>
14 </g>
15 </g>
16 </g>
17</svg> \ No newline at end of file
diff --git a/client/tsconfig.json b/client/tsconfig.json
index 60c343867..6ac5e6a9e 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -28,5 +28,11 @@
28 "stream": [ "./shims/noop" ], 28 "stream": [ "./shims/noop" ],
29 "crypto": [ "./shims/noop" ] 29 "crypto": [ "./shims/noop" ]
30 } 30 }
31 } 31 },
32 "exclude": [
33 "../node_modules",
34 "node_modules",
35 "dist",
36 "../server"
37 ]
32} 38}
diff --git a/server/helpers/custom-validators/search.ts b/server/helpers/custom-validators/search.ts
index 2fde39160..15b389a58 100644
--- a/server/helpers/custom-validators/search.ts
+++ b/server/helpers/custom-validators/search.ts
@@ -11,9 +11,14 @@ function isStringArray (value: any) {
11 return isArray(value) && value.every(v => typeof v === 'string') 11 return isArray(value) && value.every(v => typeof v === 'string')
12} 12}
13 13
14function isNSFWQueryValid (value: any) {
15 return value === 'true' || value === 'false' || value === 'both'
16}
17
14// --------------------------------------------------------------------------- 18// ---------------------------------------------------------------------------
15 19
16export { 20export {
17 isNumberArray, 21 isNumberArray,
18 isStringArray 22 isStringArray,
23 isNSFWQueryValid
19} 24}
diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts
index 5bf1e1a5f..76440348f 100644
--- a/server/helpers/express-utils.ts
+++ b/server/helpers/express-utils.ts
@@ -5,8 +5,10 @@ import { logger } from './logger'
5import { User } from '../../shared/models/users' 5import { User } from '../../shared/models/users'
6import { generateRandomString } from './utils' 6import { generateRandomString } from './utils'
7 7
8function buildNSFWFilter (res: express.Response, paramNSFW?: boolean) { 8function buildNSFWFilter (res: express.Response, paramNSFW?: string) {
9 if (paramNSFW === true || paramNSFW === false) return paramNSFW 9 if (paramNSFW === 'true') return true
10 if (paramNSFW === 'false') return false
11 if (paramNSFW === 'both') return undefined
10 12
11 if (res.locals.oauth) { 13 if (res.locals.oauth) {
12 const user: User = res.locals.oauth.token.User 14 const user: User = res.locals.oauth.token.User
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 045f41a96..d95e34bce 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -86,8 +86,6 @@ async function initDatabaseModels (silent: boolean) {
86 // Create custom PostgreSQL functions 86 // Create custom PostgreSQL functions
87 await createFunctions() 87 await createFunctions()
88 88
89 await sequelizeTypescript.query('CREATE EXTENSION IF NOT EXISTS pg_trgm', { raw: true })
90
91 if (!silent) logger.info('Database %s is ready.', dbname) 89 if (!silent) logger.info('Database %s is ready.', dbname)
92 90
93 return 91 return
diff --git a/server/middlewares/validators/search.ts b/server/middlewares/validators/search.ts
index fb2148eb3..a97f5b581 100644
--- a/server/middlewares/validators/search.ts
+++ b/server/middlewares/validators/search.ts
@@ -2,7 +2,7 @@ import * as express from 'express'
2import { areValidationErrors } from './utils' 2import { areValidationErrors } from './utils'
3import { logger } from '../../helpers/logger' 3import { logger } from '../../helpers/logger'
4import { query } from 'express-validator/check' 4import { query } from 'express-validator/check'
5import { isNumberArray, isStringArray } from '../../helpers/custom-validators/search' 5import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search'
6import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc' 6import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc'
7 7
8const searchValidator = [ 8const searchValidator = [
@@ -46,8 +46,7 @@ const commonVideosFiltersValidator = [
46 .custom(isStringArray).withMessage('Should have a valid all of tags array'), 46 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
47 query('nsfw') 47 query('nsfw')
48 .optional() 48 .optional()
49 .toBoolean() 49 .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
50 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
51 50
52 (req: express.Request, res: express.Response, next: express.NextFunction) => { 51 (req: express.Request, res: express.Response, next: express.NextFunction) => {
53 logger.debug('Checking commons video filters query', { parameters: req.query }) 52 logger.debug('Checking commons video filters query', { parameters: req.query })
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 68116e309..b97dfd96f 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -851,7 +851,22 @@ export class VideoModel extends Model<VideoModel> {
851 }) 851 })
852 } 852 }
853 853
854 static async searchAndPopulateAccountAndServer (options: VideosSearchQuery) { 854 static async searchAndPopulateAccountAndServer (options: {
855 search: string
856 start?: number
857 count?: number
858 sort?: string
859 startDate?: string // ISO 8601
860 endDate?: string // ISO 8601
861 nsfw?: boolean
862 categoryOneOf?: number[]
863 licenceOneOf?: number[]
864 languageOneOf?: string[]
865 tagsOneOf?: string[]
866 tagsAllOf?: string[]
867 durationMin?: number // seconds
868 durationMax?: number // seconds
869 }) {
855 const whereAnd = [ ] 870 const whereAnd = [ ]
856 871
857 if (options.startDate || options.endDate) { 872 if (options.startDate || options.endDate) {
diff --git a/server/tests/api/search/search-videos.ts b/server/tests/api/search/search-videos.ts
index 7fc133b46..d2b0f0312 100644
--- a/server/tests/api/search/search-videos.ts
+++ b/server/tests/api/search/search-videos.ts
@@ -216,7 +216,7 @@ describe('Test a videos search', function () {
216 search: '1111 2222 3333', 216 search: '1111 2222 3333',
217 languageOneOf: [ 'pl', 'fr' ], 217 languageOneOf: [ 'pl', 'fr' ],
218 durationMax: 4, 218 durationMax: 4,
219 nsfw: false, 219 nsfw: 'false' as 'false',
220 licenceOneOf: [ 1, 4 ] 220 licenceOneOf: [ 1, 4 ]
221 } 221 }
222 222
@@ -235,7 +235,7 @@ describe('Test a videos search', function () {
235 search: '1111 2222 3333', 235 search: '1111 2222 3333',
236 languageOneOf: [ 'pl', 'fr' ], 236 languageOneOf: [ 'pl', 'fr' ],
237 durationMax: 4, 237 durationMax: 4,
238 nsfw: false, 238 nsfw: 'false' as 'false',
239 licenceOneOf: [ 1, 4 ], 239 licenceOneOf: [ 1, 4 ],
240 sort: '-name' 240 sort: '-name'
241 } 241 }
@@ -255,7 +255,7 @@ describe('Test a videos search', function () {
255 search: '1111 2222 3333', 255 search: '1111 2222 3333',
256 languageOneOf: [ 'pl', 'fr' ], 256 languageOneOf: [ 'pl', 'fr' ],
257 durationMax: 4, 257 durationMax: 4,
258 nsfw: false, 258 nsfw: 'false' as 'false',
259 licenceOneOf: [ 1, 4 ], 259 licenceOneOf: [ 1, 4 ],
260 sort: '-name', 260 sort: '-name',
261 start: 0, 261 start: 0,
@@ -274,7 +274,7 @@ describe('Test a videos search', function () {
274 search: '1111 2222 3333', 274 search: '1111 2222 3333',
275 languageOneOf: [ 'pl', 'fr' ], 275 languageOneOf: [ 'pl', 'fr' ],
276 durationMax: 4, 276 durationMax: 4,
277 nsfw: false, 277 nsfw: 'false' as 'false',
278 licenceOneOf: [ 1, 4 ], 278 licenceOneOf: [ 1, 4 ],
279 sort: '-name', 279 sort: '-name',
280 start: 3, 280 start: 3,
diff --git a/server/tests/api/videos/video-nsfw.ts b/server/tests/api/videos/video-nsfw.ts
index 38bdaa54e..370e69d2a 100644
--- a/server/tests/api/videos/video-nsfw.ts
+++ b/server/tests/api/videos/video-nsfw.ts
@@ -220,6 +220,17 @@ describe('Test video NSFW policy', function () {
220 expect(videos[ 0 ].name).to.equal('normal') 220 expect(videos[ 0 ].name).to.equal('normal')
221 } 221 }
222 }) 222 })
223
224 it('Should display both videos when the nsfw param === both', async function () {
225 for (const res of await getVideosFunctions(server.accessToken, { nsfw: 'both' })) {
226 expect(res.body.total).to.equal(2)
227
228 const videos = res.body.data
229 expect(videos).to.have.lengthOf(2)
230 expect(videos[ 0 ].name).to.equal('normal')
231 expect(videos[ 1 ].name).to.equal('nsfw')
232 }
233 })
223 }) 234 })
224 235
225 after(async function () { 236 after(async function () {
diff --git a/shared/models/search/index.ts b/shared/models/search/index.ts
index 288ee41ef..928846c39 100644
--- a/shared/models/search/index.ts
+++ b/shared/models/search/index.ts
@@ -1 +1,2 @@
1export * from './nsfw-query.model'
1export * from './videos-search-query.model' 2export * from './videos-search-query.model'
diff --git a/shared/models/search/nsfw-query.model.ts b/shared/models/search/nsfw-query.model.ts
new file mode 100644
index 000000000..6b6ad1991
--- /dev/null
+++ b/shared/models/search/nsfw-query.model.ts
@@ -0,0 +1 @@
export type NSFWQuery = 'true' | 'false' | 'both'
diff --git a/shared/models/search/videos-search-query.model.ts b/shared/models/search/videos-search-query.model.ts
index bb23bd636..dc14b1177 100644
--- a/shared/models/search/videos-search-query.model.ts
+++ b/shared/models/search/videos-search-query.model.ts
@@ -1,3 +1,5 @@
1import { NSFWQuery } from './nsfw-query.model'
2
1export interface VideosSearchQuery { 3export interface VideosSearchQuery {
2 search: string 4 search: string
3 5
@@ -8,7 +10,7 @@ export interface VideosSearchQuery {
8 startDate?: string // ISO 8601 10 startDate?: string // ISO 8601
9 endDate?: string // ISO 8601 11 endDate?: string // ISO 8601
10 12
11 nsfw?: boolean 13 nsfw?: NSFWQuery
12 14
13 categoryOneOf?: number[] 15 categoryOneOf?: number[]
14 16