From: Chocobozzz Date: Fri, 20 Jul 2018 16:31:49 +0000 (+0200) Subject: Add advanced search in client X-Git-Tag: v1.0.0-beta.10.pre.1~31 X-Git-Url: https://git.librecmc.org/?a=commitdiff_plain;h=0b18f4aa80df8868bf34605423c7a298dffbb2aa;p=oweals%2Fpeertube.git Add advanced search in client --- 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( 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 @@ + + + + filter-ios + Created with Sketch. + + + + + + + + + + + + \ 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[]