diff options
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 @@ | |||
1 | import { NSFWQuery } from '../../../../shared/models/search' | ||
2 | |||
3 | export 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 | |||
4 | form { | ||
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 | |||
28 | input[type=text] { | ||
29 | @include peertube-input-text(100%); | ||
30 | display: block; | ||
31 | } | ||
32 | |||
33 | input[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 @@ | |||
1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' | ||
2 | import { ActivatedRoute } from '@angular/router' | ||
3 | import { RedirectService, ServerService } from '@app/core' | ||
4 | import { NotificationsService } from 'angular2-notifications' | ||
5 | import { SearchService } from '@app/search/search.service' | ||
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
7 | import { MetaService } from '@ngx-meta/core' | ||
8 | import { AdvancedSearch } from '@app/search/advanced-search.model' | ||
9 | import { VideoConstant } from '../../../../shared' | ||
10 | |||
11 | @Component({ | ||
12 | selector: 'my-search-filters', | ||
13 | styleUrls: [ './search-filters.component.scss' ], | ||
14 | templateUrl: './search-filters.component.html' | ||
15 | }) | ||
16 | export 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 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | 1 | import { Component, OnDestroy, OnInit } from '@angular/core' |
2 | import { ActivatedRoute } from '@angular/router' | 2 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { RedirectService } from '@app/core' | 3 | import { RedirectService } from '@app/core' |
4 | import { NotificationsService } from 'angular2-notifications' | 4 | import { NotificationsService } from 'angular2-notifications' |
5 | import { Subscription } from 'rxjs' | 5 | import { Subscription } from 'rxjs' |
@@ -8,6 +8,7 @@ import { ComponentPagination } from '@app/shared/rest/component-pagination.model | |||
8 | import { I18n } from '@ngx-translate/i18n-polyfill' | 8 | import { I18n } from '@ngx-translate/i18n-polyfill' |
9 | import { Video } from '../../../../shared' | 9 | import { Video } from '../../../../shared' |
10 | import { MetaService } from '@ngx-meta/core' | 10 | import { MetaService } from '@ngx-meta/core' |
11 | import { 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' | |||
3 | import { SearchComponent } from '@app/search/search.component' | 3 | import { SearchComponent } from '@app/search/search.component' |
4 | import { SearchService } from '@app/search/search.service' | 4 | import { SearchService } from '@app/search/search.service' |
5 | import { SearchRoutingModule } from '@app/search/search-routing.module' | 5 | import { SearchRoutingModule } from '@app/search/search-routing.module' |
6 | import { SearchFiltersComponent } from '@app/search/search-filters.component' | ||
7 | import { 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' | |||
8 | import { environment } from 'environments/environment' | 8 | import { environment } from 'environments/environment' |
9 | import { ResultList, Video } from '../../../../shared' | 9 | import { ResultList, Video } from '../../../../shared' |
10 | import { Video as VideoServerModel } from '@app/shared/video/video.model' | 10 | import { Video as VideoServerModel } from '@app/shared/video/video.model' |
11 | import { AdvancedSearch } from '@app/search/advanced-search.model' | ||
11 | 12 | ||
12 | export type SearchResult = { | 13 | export 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 | ||
14 | function isNSFWQueryValid (value: any) { | ||
15 | return value === 'true' || value === 'false' || value === 'both' | ||
16 | } | ||
17 | |||
14 | // --------------------------------------------------------------------------- | 18 | // --------------------------------------------------------------------------- |
15 | 19 | ||
16 | export { | 20 | export { |
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' | |||
5 | import { User } from '../../shared/models/users' | 5 | import { User } from '../../shared/models/users' |
6 | import { generateRandomString } from './utils' | 6 | import { generateRandomString } from './utils' |
7 | 7 | ||
8 | function buildNSFWFilter (res: express.Response, paramNSFW?: boolean) { | 8 | function 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' | |||
2 | import { areValidationErrors } from './utils' | 2 | import { areValidationErrors } from './utils' |
3 | import { logger } from '../../helpers/logger' | 3 | import { logger } from '../../helpers/logger' |
4 | import { query } from 'express-validator/check' | 4 | import { query } from 'express-validator/check' |
5 | import { isNumberArray, isStringArray } from '../../helpers/custom-validators/search' | 5 | import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search' |
6 | import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc' | 6 | import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc' |
7 | 7 | ||
8 | const searchValidator = [ | 8 | const 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 @@ | |||
1 | export * from './nsfw-query.model' | ||
1 | export * from './videos-search-query.model' | 2 | export * 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 @@ | |||
1 | import { NSFWQuery } from './nsfw-query.model' | ||
2 | |||
1 | export interface VideosSearchQuery { | 3 | export 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 | ||