From 0b18f4aa80df8868bf34605423c7a298dffbb2aa Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 20 Jul 2018 18:31:49 +0200 Subject: Add advanced search in client --- client/src/app/search/advanced-search.model.ts | 101 ++++++++++++ .../src/app/search/search-filters.component.html | 87 +++++++++++ .../src/app/search/search-filters.component.scss | 40 +++++ client/src/app/search/search-filters.component.ts | 170 +++++++++++++++++++++ client/src/app/search/search.component.html | 30 +++- client/src/app/search/search.component.scss | 46 +++++- client/src/app/search/search.component.ts | 29 +++- client/src/app/search/search.module.ts | 9 +- client/src/app/search/search.service.ts | 17 ++- 9 files changed, 511 insertions(+), 18 deletions(-) create mode 100644 client/src/app/search/advanced-search.model.ts create mode 100644 client/src/app/search/search-filters.component.html create mode 100644 client/src/app/search/search-filters.component.scss create mode 100644 client/src/app/search/search-filters.component.ts (limited to 'client/src/app') 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 @@ +import { NSFWQuery } from '../../../../shared/models/search' + +export class AdvancedSearch { + startDate: string // ISO 8601 + endDate: string // ISO 8601 + + nsfw: NSFWQuery + + categoryOneOf: string + + licenceOneOf: string + + languageOneOf: string + + tagsOneOf: string + tagsAllOf: string + + durationMin: number // seconds + durationMax: number // seconds + + constructor (options?: { + startDate?: string + endDate?: string + nsfw?: NSFWQuery + categoryOneOf?: string + licenceOneOf?: string + languageOneOf?: string + tagsOneOf?: string + tagsAllOf?: string + durationMin?: string + durationMax?: string + }) { + if (!options) return + + this.startDate = options.startDate + this.endDate = options.endDate + this.nsfw = options.nsfw + this.categoryOneOf = options.categoryOneOf + this.licenceOneOf = options.licenceOneOf + this.languageOneOf = options.languageOneOf + this.tagsOneOf = options.tagsOneOf + this.tagsAllOf = options.tagsAllOf + this.durationMin = parseInt(options.durationMin, 10) + this.durationMax = parseInt(options.durationMax, 10) + + if (isNaN(this.durationMin)) this.durationMin = undefined + if (isNaN(this.durationMax)) this.durationMax = undefined + } + + containsValues () { + const obj = this.toUrlObject() + for (const k of Object.keys(obj)) { + if (obj[k] !== undefined) return true + } + + return false + } + + reset () { + this.startDate = undefined + this.endDate = undefined + this.nsfw = undefined + this.categoryOneOf = undefined + this.licenceOneOf = undefined + this.languageOneOf = undefined + this.tagsOneOf = undefined + this.tagsAllOf = undefined + this.durationMin = undefined + this.durationMax = undefined + } + + toUrlObject () { + return { + startDate: this.startDate, + endDate: this.endDate, + nsfw: this.nsfw, + categoryOneOf: this.categoryOneOf, + licenceOneOf: this.licenceOneOf, + languageOneOf: this.languageOneOf, + tagsOneOf: this.tagsOneOf, + tagsAllOf: this.tagsAllOf, + durationMin: this.durationMin, + durationMax: this.durationMax + } + } + + toAPIObject () { + return { + startDate: this.startDate, + endDate: this.endDate, + nsfw: this.nsfw, + categoryOneOf: this.categoryOneOf ? this.categoryOneOf.split(',') : undefined, + licenceOneOf: this.licenceOneOf ? this.licenceOneOf.split(',') : undefined, + languageOneOf: this.languageOneOf ? this.languageOneOf.split(',') : undefined, + tagsOneOf: this.tagsOneOf ? this.tagsOneOf.split(',') : undefined, + tagsAllOf: this.tagsAllOf ? this.tagsAllOf.split(',') : undefined, + durationMin: this.durationMin, + durationMax: this.durationMax + } + } +} 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 @@ +
+ +
+
+
+
Published date
+ +
+ + +
+
+ +
+
Duration
+ +
+ + +
+
+ +
+
Display sensitive content
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+
+ + +
+ +
+ + +
+
+
+ +
+ +
+
\ 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 @@ +@import '_variables'; +@import '_mixins'; + +form { + margin-top: 40px; +} + +.radio-label { + font-size: 15px; + font-weight: $font-bold; +} + +.peertube-radio-container { + @include peertube-radio-container; + + display: inline-block; + margin-right: 30px; +} + +.peertube-select-container { + @include peertube-select-container(auto); +} + +.form-group { + margin-bottom: 25px; +} + +input[type=text] { + @include peertube-input-text(100%); + display: block; +} + +input[type=submit] { + @include peertube-button-link; + @include orange-button; +} + +.submit-button { + text-align: right; +} \ 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 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { RedirectService, ServerService } from '@app/core' +import { NotificationsService } from 'angular2-notifications' +import { SearchService } from '@app/search/search.service' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { MetaService } from '@ngx-meta/core' +import { AdvancedSearch } from '@app/search/advanced-search.model' +import { VideoConstant } from '../../../../shared' + +@Component({ + selector: 'my-search-filters', + styleUrls: [ './search-filters.component.scss' ], + templateUrl: './search-filters.component.html' +}) +export class SearchFiltersComponent implements OnInit { + @Input() advancedSearch: AdvancedSearch = new AdvancedSearch() + + @Output() filtered = new EventEmitter() + + videoCategories: VideoConstant[] = [] + videoLicences: VideoConstant[] = [] + videoLanguages: VideoConstant[] = [] + + publishedDateRanges: { id: string, label: string }[] = [] + durationRanges: { id: string, label: string }[] = [] + + publishedDateRange: string + durationRange: string + + constructor ( + private i18n: I18n, + private route: ActivatedRoute, + private metaService: MetaService, + private redirectService: RedirectService, + private notificationsService: NotificationsService, + private searchService: SearchService, + private serverService: ServerService + ) { + this.publishedDateRanges = [ + { + id: 'today', + label: this.i18n('Today') + }, + { + id: 'last_7days', + label: this.i18n('Last 7 days') + }, + { + id: 'last_30days', + label: this.i18n('Last 30 days') + }, + { + id: 'last_365days', + label: this.i18n('Last 365 days') + } + ] + + this.durationRanges = [ + { + id: 'short', + label: this.i18n('Short (< 4 minutes)') + }, + { + id: 'long', + label: this.i18n('Long (> 10 minutes)') + }, + { + id: 'medium', + label: this.i18n('Medium (4-10 minutes)') + } + ] + } + + ngOnInit () { + this.videoCategories = this.serverService.getVideoCategories() + this.videoLicences = this.serverService.getVideoLicences() + this.videoLanguages = this.serverService.getVideoLanguages() + + this.loadFromDurationRange() + this.loadFromPublishedRange() + } + + formUpdated () { + this.updateModelFromDurationRange() + this.updateModelFromPublishedRange() + + this.filtered.emit(this.advancedSearch) + } + + private loadFromDurationRange () { + if (this.advancedSearch.durationMin || this.advancedSearch.durationMax) { + const fourMinutes = 60 * 4 + const tenMinutes = 60 * 10 + + if (this.advancedSearch.durationMin === fourMinutes && this.advancedSearch.durationMax === tenMinutes) { + this.durationRange = 'medium' + } else if (this.advancedSearch.durationMax === fourMinutes) { + this.durationRange = 'short' + } else if (this.advancedSearch.durationMin === tenMinutes) { + this.durationRange = 'long' + } + } + } + + private loadFromPublishedRange () { + if (this.advancedSearch.startDate) { + const date = new Date(this.advancedSearch.startDate) + const now = new Date() + + const diff = Math.abs(date.getTime() - now.getTime()) + + const dayMS = 1000 * 3600 * 24 + const numberOfDays = diff / dayMS + + if (numberOfDays >= 365) this.publishedDateRange = 'last_365days' + else if (numberOfDays >= 30) this.publishedDateRange = 'last_30days' + else if (numberOfDays >= 7) this.publishedDateRange = 'last_7days' + else if (numberOfDays >= 0) this.publishedDateRange = 'today' + } + } + + private updateModelFromDurationRange () { + if (!this.durationRange) return + + const fourMinutes = 60 * 4 + const tenMinutes = 60 * 10 + + switch (this.durationRange) { + case 'short': + this.advancedSearch.durationMin = undefined + this.advancedSearch.durationMax = fourMinutes + break + + case 'medium': + this.advancedSearch.durationMin = fourMinutes + this.advancedSearch.durationMax = tenMinutes + break + + case 'long': + this.advancedSearch.durationMin = tenMinutes + this.advancedSearch.durationMax = undefined + break + } + } + + private updateModelFromPublishedRange () { + if (!this.publishedDateRange) return + + // today + const date = new Date() + date.setHours(0, 0, 0, 0) + + switch (this.publishedDateRange) { + case 'last_7days': + date.setDate(date.getDate() - 7) + break + + case 'last_30days': + date.setDate(date.getDate() - 30) + break + + case 'last_365days': + date.setDate(date.getDate() - 365) + break + } + + this.advancedSearch.startDate = date.toISOString() + } +} 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 @@ -
- No results found -
-
-
- {{ pagination.totalItems | myNumberFormatter }} results for {{ currentSearch }} +
+
+
+ + {{ pagination.totalItems | myNumberFormatter }} results for {{ currentSearch }} + +
+ +
+ + Filters +
+
+ +
+ +
+
+ +
+ No results found
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 @@ @import '_mixins'; .no-result { - height: 70vh; + height: 40vh; display: flex; align-items: center; justify-content: center; @@ -11,17 +11,49 @@ } .search-result { - margin-left: 40px; - margin-top: 40px; + margin: 40px; - .results-counter { - font-size: 15px; + .results-header { + font-size: 16px; padding-bottom: 20px; margin-bottom: 30px; border-bottom: 1px solid #DADADA; - .search-value { - font-weight: $font-semibold; + .first-line { + display: flex; + flex-direction: row; + + .results-counter { + flex-grow: 1; + + .search-value { + font-weight: $font-semibold; + } + } + + .results-filter-button { + + .icon.icon-filter { + @include icon(20px); + + position: relative; + top: -1px; + margin-right: 5px; + background-image: url('../../assets/images/search/filter.svg'); + } + } + } + + .results-filter { + // Animation when we show/hide the filters + transition: max-height 0.3s; + display: block !important; + overflow: hidden !important; + max-height: 0; + + &.show { + max-height: 800px; + } } } 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 @@ import { Component, OnDestroy, OnInit } from '@angular/core' -import { ActivatedRoute } from '@angular/router' +import { ActivatedRoute, Router } from '@angular/router' import { RedirectService } from '@app/core' import { NotificationsService } from 'angular2-notifications' import { Subscription } from 'rxjs' @@ -8,6 +8,7 @@ import { ComponentPagination } from '@app/shared/rest/component-pagination.model import { I18n } from '@ngx-translate/i18n-polyfill' import { Video } from '../../../../shared' import { MetaService } from '@ngx-meta/core' +import { AdvancedSearch } from '@app/search/advanced-search.model' @Component({ selector: 'my-search', @@ -21,6 +22,8 @@ export class SearchComponent implements OnInit, OnDestroy { itemsPerPage: 10, // It's per object type (so 10 videos, 10 video channels etc) totalItems: null } + advancedSearch: AdvancedSearch = new AdvancedSearch() + isSearchFilterCollapsed = true private subActivatedRoute: Subscription private currentSearch: string @@ -28,6 +31,7 @@ export class SearchComponent implements OnInit, OnDestroy { constructor ( private i18n: I18n, private route: ActivatedRoute, + private router: Router, private metaService: MetaService, private redirectService: RedirectService, private notificationsService: NotificationsService, @@ -35,6 +39,9 @@ export class SearchComponent implements OnInit, OnDestroy { ) { } ngOnInit () { + this.advancedSearch = new AdvancedSearch(this.route.snapshot.queryParams) + if (this.advancedSearch.containsValues()) this.isSearchFilterCollapsed = false + this.subActivatedRoute = this.route.queryParams.subscribe( queryParams => { const querySearch = queryParams['search'] @@ -42,6 +49,9 @@ export class SearchComponent implements OnInit, OnDestroy { if (!querySearch) return this.redirectService.redirectToHomepage() if (querySearch === this.currentSearch) return + // Search updated, reset filters + if (this.currentSearch) this.advancedSearch.reset() + this.currentSearch = querySearch this.updateTitle() @@ -57,7 +67,7 @@ export class SearchComponent implements OnInit, OnDestroy { } search () { - return this.searchService.searchVideos(this.currentSearch, this.pagination) + return this.searchService.searchVideos(this.currentSearch, this.pagination, this.advancedSearch) .subscribe( ({ videos, totalVideos }) => { this.videos = this.videos.concat(videos) @@ -78,6 +88,14 @@ export class SearchComponent implements OnInit, OnDestroy { this.search() } + onFiltered () { + this.updateUrlFromAdvancedSearch() + // Hide the filters + this.isSearchFilterCollapsed = true + + this.reload() + } + private reload () { this.pagination.currentPage = 1 this.pagination.totalItems = null @@ -90,4 +108,11 @@ export class SearchComponent implements OnInit, OnDestroy { private updateTitle () { this.metaService.setTitle(this.i18n('Search') + ' ' + this.currentSearch) } + + private updateUrlFromAdvancedSearch () { + this.router.navigate([], { + relativeTo: this.route, + queryParams: Object.assign({}, this.advancedSearch.toUrlObject(), { search: this.currentSearch }) + }) + } } 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' import { SearchComponent } from '@app/search/search.component' import { SearchService } from '@app/search/search.service' import { SearchRoutingModule } from '@app/search/search-routing.module' +import { SearchFiltersComponent } from '@app/search/search-filters.component' +import { CollapseModule } from 'ngx-bootstrap/collapse' @NgModule({ imports: [ SearchRoutingModule, - SharedModule + SharedModule, + + CollapseModule.forRoot() ], declarations: [ - SearchComponent + SearchComponent, + SearchFiltersComponent ], 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' import { environment } from 'environments/environment' import { ResultList, Video } from '../../../../shared' import { Video as VideoServerModel } from '@app/shared/video/video.model' +import { AdvancedSearch } from '@app/search/advanced-search.model' export type SearchResult = { videosResult: { totalVideos: number, videos: Video[] } @@ -26,7 +27,8 @@ export class SearchService { searchVideos ( search: string, - componentPagination: ComponentPagination + componentPagination: ComponentPagination, + advancedSearch: AdvancedSearch ): Observable<{ videos: Video[], totalVideos: number }> { const url = SearchService.BASE_SEARCH_URL + 'videos' @@ -36,6 +38,19 @@ export class SearchService { params = this.restService.addRestGetParams(params, pagination) params = params.append('search', search) + const advancedSearchObject = advancedSearch.toAPIObject() + + for (const name of Object.keys(advancedSearchObject)) { + const value = advancedSearchObject[name] + if (!value) continue + + if (Array.isArray(value)) { + for (const v of value) params = params.append(name, v) + } else { + params = params.append(name, value) + } + } + return this.authHttp .get>(url, { params }) .pipe( -- cgit v1.2.3