583 lines
17 KiB
Vue
583 lines
17 KiB
Vue
<template>
|
|
<div>
|
|
<my-header :index-name="indexName" :small-format="true" />
|
|
|
|
<main>
|
|
<form id="search-anchor" role="search" onsubmit="return false;">
|
|
|
|
<div class="card position-relative">
|
|
<button class="warning-button popover-button">
|
|
<div class="visually-hidden">{{ $gettext('Toggle warning information') }}</div>
|
|
|
|
<icon-warning></icon-warning>
|
|
</button>
|
|
|
|
<search-warning class="d-none popover-content" :index-name="indexName" :is-block-mode="false" />
|
|
|
|
<search-input class="search-input" :label="true"></search-input>
|
|
</div>
|
|
|
|
|
|
<div class="button-rows">
|
|
<button
|
|
class="filters-button peertube-button peertube-primary-button"
|
|
:aria-expanded="displayFilters" aria-controls="filters" @click="toggleFilters()"
|
|
>
|
|
{{ $gettext('Filters') }}
|
|
<template v-if="activeFilters">({{ activeFilters }})</template>
|
|
|
|
<div class="arrow-down" />
|
|
</button>
|
|
|
|
<sort-button></sort-button>
|
|
</div>
|
|
|
|
<filters v-if="displayFilters" id="filters"></filters>
|
|
</form>
|
|
|
|
<div v-if="searched" class="results">
|
|
<div class="results-summary">
|
|
<span v-if="totalResults === 0">{{ $gettext('No results found.') }}</span>
|
|
|
|
<span v-if="totalResults">{{ $ngettext('%{totalResults} result found:', '%{totalResults} results found:', totalResults, { totalResults: totalResults.toLocaleString() }) }}</span>
|
|
</div>
|
|
|
|
<div v-if="(!hasResultTypeFilter() || getResultTypeFilter() === 'videos') && totalResults !== 0">
|
|
<h2>
|
|
<strong>
|
|
{{ $ngettext('%{totalVideos} video', '%{totalVideos} videos', totalVideos, { totalVideos: totalVideos + '' }) }}
|
|
</strong>
|
|
</h2>
|
|
|
|
<template v-if="totalVideos">
|
|
<div v-for="video in videos" :key="video.url" class="mb-4">
|
|
<video-result :video="video"></video-result>
|
|
</div>
|
|
|
|
<div v-if="!hasResultTypeFilter()" class="mb-5 text-center">
|
|
<router-link
|
|
v-if="!hasResultTypeFilter() && totalVideos > summaryResultsCount.videos"
|
|
class="peertube-button peertube-button-link peertube-secondary-button"
|
|
:to="{ path: '/search', query: { ...getMoreResultsQuery('videos') } }"
|
|
>
|
|
{{ $gettext('Display more videos') }}
|
|
</router-link>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="(!hasResultTypeFilter() || getResultTypeFilter() === 'channels') && totalResults !== 0">
|
|
<h2>
|
|
<strong>
|
|
{{ $ngettext('%{totalChannels} channel', '%{totalChannels} channels', totalChannels, { totalChannels: totalChannels + '' }) }}
|
|
</strong>
|
|
</h2>
|
|
|
|
<template v-if="totalChannels">
|
|
<div v-for="channel in channels" :key="channel.url" class="mb-4">
|
|
<channel-result :channel="channel"></channel-result>
|
|
</div>
|
|
|
|
<div class="mb-5 text-center">
|
|
<router-link
|
|
v-if="!hasResultTypeFilter() && totalChannels > summaryResultsCount.channels"
|
|
class="peertube-button peertube-button-link peertube-secondary-button"
|
|
:to="{ path: '/search', query: { ...getMoreResultsQuery('channels') } }"
|
|
>
|
|
{{ $gettext('Display more channels') }}
|
|
</router-link>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="(!hasResultTypeFilter() || getResultTypeFilter() === 'playlists') && totalResults !== 0">
|
|
<h2>
|
|
<strong>
|
|
{{ $ngettext('%{totalPlaylists} playlist', '%{totalPlaylists} playlists', totalPlaylists, { totalPlaylists: totalPlaylists + '' }) }}
|
|
</strong>
|
|
</h2>
|
|
|
|
<template v-if="totalPlaylists">
|
|
<div v-for="playlist in playlists" :key="playlist.url" class="mb-4">
|
|
<playlist-result :playlist="playlist"></playlist-result>
|
|
</div>
|
|
|
|
<div class="mb-5 text-center">
|
|
<router-link
|
|
v-if="!hasResultTypeFilter() && totalPlaylists > summaryResultsCount.playlists"
|
|
class="peertube-button peertube-button-link peertube-secondary-button"
|
|
:to="{ path: '/search', query: { ...getMoreResultsQuery('playlists') } }"
|
|
>
|
|
{{ $gettext('Display more playlists') }}
|
|
</router-link>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<pagination v-if="hasResultTypeFilter()" v-model="currentPage" :max-page="getMaxPage()" :searched="searched" :pages="pages" />
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { defineComponent } from 'vue'
|
|
import Header from '../components/Header.vue'
|
|
import SearchWarning from '../components/SearchWarning.vue'
|
|
import VideoResult from '../components/VideoResult.vue'
|
|
import ChannelResult from '../components/ChannelResult.vue'
|
|
import PlaylistResult from '../components/PlaylistResult.vue'
|
|
import IconWarning from '../components/icons/IconWarning.vue'
|
|
import Filters from '../components/Filters.vue'
|
|
import { searchVideos, searchVideoChannels, searchVideoPlaylists } from '../shared/search'
|
|
import { getConfig } from '../shared/config'
|
|
import { pageToAPIParams, durationRangeToAPIParams, publishedDateRangeToAPIParams, extractTagsFromQuery, extractQueryToIntArray, extractQueryToStringArray, extractQueryToInt, extractQueryToBoolean } from '../shared/utils'
|
|
import { SearchUrl } from '../models'
|
|
import { APIVideo } from '../../../server/types/video.model'
|
|
import { APIVideoChannel } from '../../../server/types/channel.model'
|
|
import Pagination from '../components/Pagination.vue'
|
|
import SearchInput from '../components/SearchInput.vue'
|
|
import SortButton from '../components/SortButton.vue'
|
|
import type { VideoChannelsSearchQuery, ResultList, VideosSearchQuery } from '@peertube/peertube-types'
|
|
import Nprogress from 'nprogress'
|
|
import { APIPlaylist } from '../../../server/types/playlist.model'
|
|
import { PlaylistsSearchQuery } from '../../../server/types/search-query/playlist-search.model'
|
|
|
|
export default defineComponent({
|
|
components: {
|
|
'my-header': Header,
|
|
'search-warning': SearchWarning,
|
|
'video-result': VideoResult,
|
|
'channel-result': ChannelResult,
|
|
'playlist-result': PlaylistResult,
|
|
'search-input': SearchInput,
|
|
'pagination': Pagination,
|
|
'filters': Filters,
|
|
'sort-button': SortButton,
|
|
'icon-warning': IconWarning
|
|
},
|
|
|
|
data () {
|
|
return {
|
|
searched: false,
|
|
|
|
indexName: '',
|
|
|
|
totalResults: null as number,
|
|
|
|
totalVideos: null as number,
|
|
videos: null as APIVideo[],
|
|
|
|
totalChannels: null as number,
|
|
channels: null as APIVideoChannel[],
|
|
|
|
totalPlaylists: null as number,
|
|
playlists: null as APIPlaylist[],
|
|
|
|
currentPage: 1,
|
|
pages: [],
|
|
|
|
summaryResultsCount: {
|
|
videos: 5,
|
|
channels: 2,
|
|
playlists: 2
|
|
},
|
|
filteredTypeResultsPerPage: 10,
|
|
|
|
activeFilters: 0,
|
|
displayFilters: false
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
boostLanguagesQuery (): string[] {
|
|
const languages = new Set<string>()
|
|
|
|
for (const completeLanguage of navigator.languages) {
|
|
languages.add(completeLanguage.split('-')[0])
|
|
}
|
|
|
|
return Array.from(languages)
|
|
}
|
|
},
|
|
|
|
watch: {
|
|
$route() {
|
|
this.loadUrl()
|
|
this.doSearch()
|
|
}
|
|
},
|
|
|
|
mounted () {
|
|
// eslint-disable-next-line no-new
|
|
import('bootstrap/js/dist/popover')
|
|
.then(Popover => {
|
|
new Popover.default(this.$el.querySelector('.popover-button'), {
|
|
content: document.querySelector('.popover-content'),
|
|
sanitize: false,
|
|
trigger: 'focus'
|
|
})
|
|
})
|
|
|
|
const config = getConfig()
|
|
|
|
this.indexName = config.searchInstanceName
|
|
|
|
if (Object.keys(this.$route.query).length !== 0) {
|
|
this.loadUrl()
|
|
this.doSearch()
|
|
}
|
|
},
|
|
|
|
methods: {
|
|
|
|
async doSearch () {
|
|
this.videos = []
|
|
this.channels = []
|
|
this.playlists = []
|
|
this.searched = false
|
|
|
|
Nprogress.start()
|
|
|
|
try {
|
|
const [ videosResult, channelsResult, playlistsResult ] = await Promise.all([
|
|
this.searchVideos(),
|
|
this.searchChannels(),
|
|
this.searchPlaylists()
|
|
])
|
|
|
|
Nprogress.done()
|
|
|
|
this.activeFilters = this.countActiveFilters()
|
|
|
|
|
|
this.totalVideos = videosResult.total
|
|
this.videos = videosResult.data
|
|
|
|
this.totalChannels = channelsResult.total
|
|
this.channels = channelsResult.data
|
|
|
|
this.totalPlaylists = playlistsResult.total
|
|
this.playlists = playlistsResult.data
|
|
|
|
this.totalResults = videosResult.total + channelsResult.total + playlistsResult.total
|
|
|
|
this.buildPages()
|
|
this.searched = true
|
|
} catch (err) {
|
|
console.error('Cannot do search.', err)
|
|
} finally {
|
|
this.searched = true
|
|
Nprogress.done()
|
|
}
|
|
},
|
|
|
|
loadUrl () {
|
|
const query = this.$route.query as SearchUrl
|
|
const queryPage = extractQueryToInt(query.page)
|
|
|
|
this.currentPage = queryPage && this.currentPage !== queryPage
|
|
? queryPage
|
|
: 1
|
|
},
|
|
|
|
countActiveFilters () {
|
|
const query = this.$route.query as SearchUrl
|
|
|
|
let count = 0
|
|
|
|
if (query.nsfw) count++
|
|
if (query.host) count++
|
|
if (query.publishedDateRange) count++
|
|
if (query.durationRange) count++
|
|
if (query.categoryOneOf) count++
|
|
if (query.licenceOneOf) count++
|
|
if (query.languageOneOf) count++
|
|
if (query.isLive) count++
|
|
if (query.resultType) count++
|
|
if (Array.isArray(query.tagsAllOf) && query.tagsAllOf.length !== 0) count++
|
|
if (Array.isArray(query.tagsOneOf) && query.tagsOneOf.length !== 0) count++
|
|
|
|
return count
|
|
},
|
|
|
|
buildVideoSearchQuery () {
|
|
const query = this.$route.query as SearchUrl
|
|
|
|
const resultsPerPage = this.getResultTypeFilter() === 'videos'
|
|
? this.filteredTypeResultsPerPage
|
|
: this.summaryResultsCount.videos
|
|
|
|
const { start, count } = pageToAPIParams(this.currentPage, resultsPerPage)
|
|
const { durationMin, durationMax } = durationRangeToAPIParams(query.durationRange)
|
|
const { startDate, endDate } = publishedDateRangeToAPIParams(query.publishedDateRange)
|
|
|
|
const boostLanguages = this.boostLanguagesQuery
|
|
|
|
let sort: string
|
|
if (query.sort === '-matched') sort = '-matched'
|
|
else if (query.sort === '-createdAt') sort = '-publishedAt'
|
|
else if (query.sort === 'createdAt') sort = 'publishedAt'
|
|
|
|
return {
|
|
search: query.search,
|
|
|
|
durationMin,
|
|
durationMax,
|
|
|
|
startDate,
|
|
endDate,
|
|
|
|
boostLanguages,
|
|
|
|
nsfw: query.nsfw || false,
|
|
|
|
categoryOneOf: extractQueryToIntArray(query.categoryOneOf),
|
|
licenceOneOf: extractQueryToIntArray(query.licenceOneOf),
|
|
languageOneOf: extractQueryToStringArray(query.languageOneOf),
|
|
|
|
tagsOneOf: extractTagsFromQuery(query.tagsOneOf).map(t => t.text),
|
|
tagsAllOf: extractTagsFromQuery(query.tagsAllOf).map(t => t.text),
|
|
|
|
isLive: extractQueryToBoolean(query.isLive),
|
|
|
|
host: query.host || undefined,
|
|
|
|
start,
|
|
count,
|
|
sort
|
|
} as VideosSearchQuery
|
|
},
|
|
|
|
buildChannelSearchQuery () {
|
|
const query = this.$route.query as SearchUrl
|
|
|
|
const resultsPerPage = this.getResultTypeFilter() === 'channels'
|
|
? this.filteredTypeResultsPerPage
|
|
: this.summaryResultsCount.channels
|
|
|
|
const { start, count } = pageToAPIParams(this.currentPage, resultsPerPage)
|
|
|
|
return {
|
|
search: query.search,
|
|
host: query.host || undefined,
|
|
start,
|
|
sort: query.sort,
|
|
count
|
|
} as VideoChannelsSearchQuery
|
|
},
|
|
|
|
buildPlaylistSearchQuery () {
|
|
const query = this.$route.query as SearchUrl
|
|
|
|
const resultsPerPage = this.getResultTypeFilter() === 'playlists'
|
|
? this.filteredTypeResultsPerPage
|
|
: this.summaryResultsCount.playlists
|
|
|
|
const { start, count } = pageToAPIParams(this.currentPage, resultsPerPage)
|
|
|
|
return {
|
|
search: query.search,
|
|
host: query.host || undefined,
|
|
start,
|
|
sort: query.sort,
|
|
count
|
|
} as PlaylistsSearchQuery
|
|
},
|
|
|
|
searchVideos (): Promise<ResultList<APIVideo>> {
|
|
if (this.isVideoSearchDisabled()) {
|
|
return Promise.resolve({ total: 0, data: [] })
|
|
}
|
|
|
|
const query = this.buildVideoSearchQuery()
|
|
|
|
return searchVideos(query)
|
|
},
|
|
|
|
searchChannels (): Promise<ResultList<APIVideoChannel>> {
|
|
if (this.isChannelSearchDisabled()) {
|
|
return Promise.resolve({ data: [], total: 0 })
|
|
}
|
|
|
|
const query = this.buildChannelSearchQuery()
|
|
|
|
return searchVideoChannels(query)
|
|
},
|
|
|
|
searchPlaylists (): Promise<ResultList<APIPlaylist>> {
|
|
if (this.isPlaylistSearchDisabled()) {
|
|
return Promise.resolve({ data: [], total: 0 })
|
|
}
|
|
|
|
const query = this.buildPlaylistSearchQuery()
|
|
|
|
return searchVideoPlaylists(query)
|
|
},
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
getMaxPage () {
|
|
const resultType = (this.$route.query as SearchUrl).resultType
|
|
|
|
let maxPage: number
|
|
|
|
if (resultType === 'videos') maxPage = Math.ceil(this.totalVideos / this.filteredTypeResultsPerPage)
|
|
else if (resultType === 'channels') maxPage = Math.ceil(this.totalChannels / this.filteredTypeResultsPerPage)
|
|
else if (resultType === 'playlists') maxPage = Math.ceil(this.totalPlaylists / this.filteredTypeResultsPerPage)
|
|
|
|
// Limit to 10 pages
|
|
return Math.min(10, maxPage)
|
|
},
|
|
|
|
buildPages () {
|
|
this.pages = []
|
|
|
|
for (let i = 1; i <= this.getMaxPage(); i++) {
|
|
this.pages.push(i)
|
|
}
|
|
},
|
|
|
|
toggleFilters () {
|
|
this.displayFilters = !this.displayFilters
|
|
},
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
getResultTypeFilter () {
|
|
return (this.$route.query as SearchUrl).resultType
|
|
},
|
|
|
|
hasResultTypeFilter () {
|
|
return !!this.getResultTypeFilter()
|
|
},
|
|
|
|
getMoreResultsQuery (type: 'videos' | 'channels' | 'playlists'): SearchUrl {
|
|
return {
|
|
...this.$route.query,
|
|
|
|
resultType: type
|
|
}
|
|
},
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
isVideoSearchDisabled () {
|
|
const { resultType } = this.$route.query as SearchUrl
|
|
|
|
if (resultType !== undefined && resultType !== 'videos') return true
|
|
|
|
return false
|
|
},
|
|
|
|
isChannelSearchDisabled () {
|
|
const { resultType, host } = this.$route.query as SearchUrl
|
|
|
|
if (resultType !== undefined) {
|
|
if (resultType !== 'channels') return true
|
|
|
|
return false
|
|
}
|
|
|
|
// We can search against host for playlists and channels
|
|
if (host) return this.countActiveFilters() > 1
|
|
|
|
return this.countActiveFilters() > 0
|
|
},
|
|
|
|
isPlaylistSearchDisabled () {
|
|
const { resultType, host } = this.$route.query as SearchUrl
|
|
|
|
if (resultType !== undefined) {
|
|
if (resultType !== 'playlists') return true
|
|
|
|
return false
|
|
}
|
|
|
|
// We can search against host for playlists and channels
|
|
if (host) return this.countActiveFilters() > 1
|
|
|
|
return this.countActiveFilters() > 0
|
|
}
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
@use 'sass:math';
|
|
|
|
@import '../scss/_variables';
|
|
@import '../scss/bootstrap-mixins';
|
|
|
|
.search-input {
|
|
max-width: 700px;
|
|
margin: auto;
|
|
}
|
|
|
|
.warning-button {
|
|
background: transparent;
|
|
border: none;
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
|
|
svg {
|
|
width: 30px;
|
|
height: 30px;
|
|
}
|
|
|
|
&:hover {
|
|
opacity: 0.8;
|
|
}
|
|
}
|
|
|
|
.filters-button {
|
|
position: relative;
|
|
padding-left: 1rem;
|
|
padding-right: 2rem;
|
|
|
|
.arrow-down {
|
|
$size: 4px;
|
|
|
|
position: absolute;
|
|
right: 0;
|
|
top: 50%;
|
|
margin-top: math.div(-$size, 2);
|
|
margin-left: 1.5rem;
|
|
margin-right: 0.75rem;
|
|
width: 0;
|
|
height: 0;
|
|
border-left: $size solid transparent;
|
|
border-right: $size solid transparent;
|
|
|
|
border-top: $size solid #fff;
|
|
}
|
|
}
|
|
|
|
.button-rows {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
|
|
> * {
|
|
@include margin-top(2rem);
|
|
}
|
|
}
|
|
|
|
.results {
|
|
@include margin(2rem 0);
|
|
}
|
|
|
|
.results-summary {
|
|
@include margin-bottom(3rem);
|
|
|
|
font-weight: $font-semibold;
|
|
text-align: center;
|
|
}
|
|
|
|
h2 {
|
|
@include font-size(1.125rem);
|
|
}
|
|
|
|
</style>
|