1
0
Fork 0

Add advanced search in client

This commit is contained in:
Chocobozzz 2018-07-20 18:31:49 +02:00
parent d525fc399a
commit 0b18f4aa80
21 changed files with 583 additions and 33 deletions

View 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
}
}
}

View 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>

View 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;
}

View 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()
}
}

View file

@ -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">

View file

@ -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;
}
}
}

View file

@ -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 })
})
}
}

View file

@ -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: [

View file

@ -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(

View 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

View file

@ -28,5 +28,11 @@
"stream": [ "./shims/noop" ],
"crypto": [ "./shims/noop" ]
}
}
},
"exclude": [
"../node_modules",
"node_modules",
"dist",
"../server"
]
}

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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 })

View file

@ -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) {

View file

@ -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,

View file

@ -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 () {

View file

@ -1 +1,2 @@
export * from './nsfw-query.model'
export * from './videos-search-query.model'

View file

@ -0,0 +1 @@
export type NSFWQuery = 'true' | 'false' | 'both'

View file

@ -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[]