- {{ pagination.totalItems | myNumberFormatter }} results for
{{ currentSearch }}
+
+
+
+ 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(
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 @@
+
+
\ 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 @@
"stream": [ "./shims/noop" ],
"crypto": [ "./shims/noop" ]
}
- }
+ },
+ "exclude": [
+ "../node_modules",
+ "node_modules",
+ "dist",
+ "../server"
+ ]
}
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) {
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
}
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'
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
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) {
// 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
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'
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 })
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 {
})
}
- 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) {
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 () {
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,
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 () {
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 () {
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 @@
+export * from './nsfw-query.model'
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 @@
+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[]