Add advanced search in client
This commit is contained in:
parent
d525fc399a
commit
0b18f4aa80
21 changed files with 583 additions and 33 deletions
101
client/src/app/search/advanced-search.model.ts
Normal file
101
client/src/app/search/advanced-search.model.ts
Normal file
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
87
client/src/app/search/search-filters.component.html
Normal file
87
client/src/app/search/search-filters.component.html
Normal file
|
@ -0,0 +1,87 @@
|
|||
<form role="form" (ngSubmit)="formUpdated()">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-4 col-md-6 col-xs-12">
|
||||
<div class="form-group">
|
||||
<div i18n class="radio-label">Published date</div>
|
||||
|
||||
<div class="peertube-radio-container" *ngFor="let date of publishedDateRanges">
|
||||
<input type="radio" name="publishedDateRange" [id]="date.id" [value]="date.id" [(ngModel)]="publishedDateRange">
|
||||
<label [for]="date.id" class="radio">{{ date.label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div i18n class="radio-label">Duration</div>
|
||||
|
||||
<div class="peertube-radio-container" *ngFor="let duration of durationRanges">
|
||||
<input type="radio" name="durationRange" [id]="duration.id" [value]="duration.id" [(ngModel)]="durationRange">
|
||||
<label [for]="duration.id" class="radio">{{ duration.label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div i18n class="radio-label">Display sensitive content</div>
|
||||
|
||||
<div class="peertube-radio-container">
|
||||
<input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" [(ngModel)]="advancedSearch.nsfw">
|
||||
<label i18n for="sensitiveContentYes" class="radio">Yes</label>
|
||||
</div>
|
||||
|
||||
<div class="peertube-radio-container">
|
||||
<input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" [(ngModel)]="advancedSearch.nsfw">
|
||||
<label i18n for="sensitiveContentNo" class="radio">No</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4 col-md-6 col-xs-12">
|
||||
<div class="form-group">
|
||||
<label i18n for="category">Category</label>
|
||||
<div class="peertube-select-container">
|
||||
<select id="category" name="category" [(ngModel)]="advancedSearch.categoryOneOf">
|
||||
<option></option>
|
||||
<option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="licence">Licence</label>
|
||||
<div class="peertube-select-container">
|
||||
<select id="licence" name="licence" [(ngModel)]="advancedSearch.licenceOneOf">
|
||||
<option></option>
|
||||
<option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="language">Language</label>
|
||||
<div class="peertube-select-container">
|
||||
<select id="language" name="language" [(ngModel)]="advancedSearch.languageOneOf">
|
||||
<option></option>
|
||||
<option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4 col-md-6 col-xs-12">
|
||||
<div class="form-group">
|
||||
<label i18n for="tagsAllOf">All of these tags</label>
|
||||
<input type="text" name="tagsAllOf" id="tagsAllOf" [(ngModel)]="advancedSearch.tagsAllOf" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="tagsOneOf">One of these tags</label>
|
||||
<input type="text" name="tagsOneOf" id="tagsOneOf" [(ngModel)]="advancedSearch.tagsOneOf" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="submit-button">
|
||||
<input type="submit" i18n-value value="Filter">
|
||||
</div>
|
||||
</form>
|
40
client/src/app/search/search-filters.component.scss
Normal file
40
client/src/app/search/search-filters.component.scss
Normal file
|
@ -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;
|
||||
}
|
170
client/src/app/search/search-filters.component.ts
Normal file
170
client/src/app/search/search-filters.component.ts
Normal file
|
@ -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<AdvancedSearch>()
|
||||
|
||||
videoCategories: VideoConstant<string>[] = []
|
||||
videoLicences: VideoConstant<string>[] = []
|
||||
videoLanguages: VideoConstant<string>[] = []
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -1,10 +1,28 @@
|
|||
<div i18n *ngIf="pagination.totalItems === 0" class="no-result">
|
||||
No results found
|
||||
</div>
|
||||
|
||||
<div myInfiniteScroller [autoLoading]="true" (nearOfBottom)="onNearOfBottom()" class="search-result">
|
||||
<div i18n *ngIf="pagination.totalItems" class="results-counter">
|
||||
{{ pagination.totalItems | myNumberFormatter }} results for <span class="search-value">{{ currentSearch }}</span>
|
||||
<div i18n class="results-header">
|
||||
<div class="first-line">
|
||||
<div class="results-counter">
|
||||
<ng-container *ngIf="pagination.totalItems">
|
||||
{{ pagination.totalItems | myNumberFormatter }} results for <span class="search-value">{{ currentSearch }}</span>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="results-filter-button" (click)="isSearchFilterCollapsed = !isSearchFilterCollapsed" role="button"
|
||||
[attr.aria-expanded]="isSearchFilterCollapsed" aria-controls="collapseBasic"
|
||||
>
|
||||
<span class="icon icon-filter"></span>
|
||||
<ng-container i18n>Filters</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-filter" [collapse]="isSearchFilterCollapsed">
|
||||
<my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered($event)"></my-search-filters>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div i18n *ngIf="pagination.totalItems === 0" class="no-result">
|
||||
No results found
|
||||
</div>
|
||||
|
||||
<div *ngFor="let video of videos" class="entry video">
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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<ResultList<VideoServerModel>>(url, { params })
|
||||
.pipe(
|
||||
|
|
17
client/src/assets/images/search/filter.svg
Normal file
17
client/src/assets/images/search/filter.svg
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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">
|
||||
<!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>filter-ios</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs></defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Artboard-4" transform="translate(-796.000000, -291.000000)">
|
||||
<g id="98" transform="translate(796.000000, 291.000000)">
|
||||
<circle id="Oval-23" stroke="#333333" stroke-width="2" cx="12" cy="12" r="10"></circle>
|
||||
<rect id="Rectangle-44" fill="#333333" x="6" y="8" width="12" height="2" rx="1"></rect>
|
||||
<rect id="Rectangle-44" fill="#333333" x="8" y="12" width="8" height="2" rx="1"></rect>
|
||||
<rect id="Rectangle-44" fill="#333333" x="10" y="16" width="4" height="2" rx="1"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1 KiB |
|
@ -28,5 +28,11 @@
|
|||
"stream": [ "./shims/noop" ],
|
||||
"crypto": [ "./shims/noop" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"../node_modules",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"../server"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -11,9 +11,14 @@ function isStringArray (value: any) {
|
|||
return isArray(value) && value.every(v => typeof v === 'string')
|
||||
}
|
||||
|
||||
function isNSFWQueryValid (value: any) {
|
||||
return value === 'true' || value === 'false' || value === 'both'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
isNumberArray,
|
||||
isStringArray
|
||||
isStringArray,
|
||||
isNSFWQueryValid
|
||||
}
|
||||
|
|
|
@ -5,8 +5,10 @@ import { logger } from './logger'
|
|||
import { User } from '../../shared/models/users'
|
||||
import { generateRandomString } from './utils'
|
||||
|
||||
function buildNSFWFilter (res: express.Response, paramNSFW?: boolean) {
|
||||
if (paramNSFW === true || paramNSFW === false) return paramNSFW
|
||||
function buildNSFWFilter (res: express.Response, paramNSFW?: string) {
|
||||
if (paramNSFW === 'true') return true
|
||||
if (paramNSFW === 'false') return false
|
||||
if (paramNSFW === 'both') return undefined
|
||||
|
||||
if (res.locals.oauth) {
|
||||
const user: User = res.locals.oauth.token.User
|
||||
|
|
|
@ -86,8 +86,6 @@ async function initDatabaseModels (silent: boolean) {
|
|||
// Create custom PostgreSQL functions
|
||||
await createFunctions()
|
||||
|
||||
await sequelizeTypescript.query('CREATE EXTENSION IF NOT EXISTS pg_trgm', { raw: true })
|
||||
|
||||
if (!silent) logger.info('Database %s is ready.', dbname)
|
||||
|
||||
return
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as express from 'express'
|
|||
import { areValidationErrors } from './utils'
|
||||
import { logger } from '../../helpers/logger'
|
||||
import { query } from 'express-validator/check'
|
||||
import { isNumberArray, isStringArray } from '../../helpers/custom-validators/search'
|
||||
import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search'
|
||||
import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc'
|
||||
|
||||
const searchValidator = [
|
||||
|
@ -46,8 +46,7 @@ const commonVideosFiltersValidator = [
|
|||
.custom(isStringArray).withMessage('Should have a valid all of tags array'),
|
||||
query('nsfw')
|
||||
.optional()
|
||||
.toBoolean()
|
||||
.custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
|
||||
.custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
|
||||
|
||||
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking commons video filters query', { parameters: req.query })
|
||||
|
|
|
@ -851,7 +851,22 @@ export class VideoModel extends Model<VideoModel> {
|
|||
})
|
||||
}
|
||||
|
||||
static async searchAndPopulateAccountAndServer (options: VideosSearchQuery) {
|
||||
static async searchAndPopulateAccountAndServer (options: {
|
||||
search: string
|
||||
start?: number
|
||||
count?: number
|
||||
sort?: string
|
||||
startDate?: string // ISO 8601
|
||||
endDate?: string // ISO 8601
|
||||
nsfw?: boolean
|
||||
categoryOneOf?: number[]
|
||||
licenceOneOf?: number[]
|
||||
languageOneOf?: string[]
|
||||
tagsOneOf?: string[]
|
||||
tagsAllOf?: string[]
|
||||
durationMin?: number // seconds
|
||||
durationMax?: number // seconds
|
||||
}) {
|
||||
const whereAnd = [ ]
|
||||
|
||||
if (options.startDate || options.endDate) {
|
||||
|
|
|
@ -216,7 +216,7 @@ describe('Test a videos search', function () {
|
|||
search: '1111 2222 3333',
|
||||
languageOneOf: [ 'pl', 'fr' ],
|
||||
durationMax: 4,
|
||||
nsfw: false,
|
||||
nsfw: 'false' as 'false',
|
||||
licenceOneOf: [ 1, 4 ]
|
||||
}
|
||||
|
||||
|
@ -235,7 +235,7 @@ describe('Test a videos search', function () {
|
|||
search: '1111 2222 3333',
|
||||
languageOneOf: [ 'pl', 'fr' ],
|
||||
durationMax: 4,
|
||||
nsfw: false,
|
||||
nsfw: 'false' as 'false',
|
||||
licenceOneOf: [ 1, 4 ],
|
||||
sort: '-name'
|
||||
}
|
||||
|
@ -255,7 +255,7 @@ describe('Test a videos search', function () {
|
|||
search: '1111 2222 3333',
|
||||
languageOneOf: [ 'pl', 'fr' ],
|
||||
durationMax: 4,
|
||||
nsfw: false,
|
||||
nsfw: 'false' as 'false',
|
||||
licenceOneOf: [ 1, 4 ],
|
||||
sort: '-name',
|
||||
start: 0,
|
||||
|
@ -274,7 +274,7 @@ describe('Test a videos search', function () {
|
|||
search: '1111 2222 3333',
|
||||
languageOneOf: [ 'pl', 'fr' ],
|
||||
durationMax: 4,
|
||||
nsfw: false,
|
||||
nsfw: 'false' as 'false',
|
||||
licenceOneOf: [ 1, 4 ],
|
||||
sort: '-name',
|
||||
start: 3,
|
||||
|
|
|
@ -220,6 +220,17 @@ describe('Test video NSFW policy', function () {
|
|||
expect(videos[ 0 ].name).to.equal('normal')
|
||||
}
|
||||
})
|
||||
|
||||
it('Should display both videos when the nsfw param === both', async function () {
|
||||
for (const res of await getVideosFunctions(server.accessToken, { nsfw: 'both' })) {
|
||||
expect(res.body.total).to.equal(2)
|
||||
|
||||
const videos = res.body.data
|
||||
expect(videos).to.have.lengthOf(2)
|
||||
expect(videos[ 0 ].name).to.equal('normal')
|
||||
expect(videos[ 1 ].name).to.equal('nsfw')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export * from './nsfw-query.model'
|
||||
export * from './videos-search-query.model'
|
||||
|
|
1
shared/models/search/nsfw-query.model.ts
Normal file
1
shared/models/search/nsfw-query.model.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type NSFWQuery = 'true' | 'false' | 'both'
|
|
@ -1,3 +1,5 @@
|
|||
import { NSFWQuery } from './nsfw-query.model'
|
||||
|
||||
export interface VideosSearchQuery {
|
||||
search: string
|
||||
|
||||
|
@ -8,7 +10,7 @@ export interface VideosSearchQuery {
|
|||
startDate?: string // ISO 8601
|
||||
endDate?: string // ISO 8601
|
||||
|
||||
nsfw?: boolean
|
||||
nsfw?: NSFWQuery
|
||||
|
||||
categoryOneOf?: number[]
|
||||
|
||||
|
|
Loading…
Reference in a new issue