467 lines
13 KiB
Vue
467 lines
13 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 class="results">
|
|
<div class="results-summary">
|
|
<span v-if="resultsCount === 0">
|
|
{{ $gettext('No results found.') }}
|
|
</span>
|
|
|
|
<span v-if="resultsCount">
|
|
{{ $ngettext('%{resultsCount} result found:', '%{resultsCount} results found:', resultsCount, { resultsCount: resultsCount.toLocaleString() }) }}
|
|
</span>
|
|
</div>
|
|
|
|
<div v-for="result in results" :key="getResultKey(result)">
|
|
<video-result v-if="isVideo(result)" :video="result" class="mb-4" />
|
|
|
|
<channel-result v-else-if="isChannel(result)" :channel="result" class="mb-4" />
|
|
|
|
<playlist-result v-else :playlist="result" class="mb-4" />
|
|
</div>
|
|
|
|
<pagination v-model="currentPage" :max-page="getMaxPage()" :searched="searched" :pages="pages" />
|
|
</div>
|
|
</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 { EnhancedVideo } from '../../../server/types/video.model'
|
|
import { EnhancedVideoChannel } from '../../../server/types/channel.model'
|
|
import Pagination from '../components/Pagination.vue'
|
|
import SearchInput from '../components/SearchInput.vue'
|
|
import SortButton from '../components/SortButton.vue'
|
|
import { VideoChannelsSearchQuery, ResultList, VideosSearchQuery } from '../../../PeerTube/shared/models'
|
|
import Nprogress from 'nprogress'
|
|
import { EnhancedPlaylist } 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: '',
|
|
results: [] as (EnhancedVideo | EnhancedVideoChannel | EnhancedPlaylist)[],
|
|
|
|
resultsCount: null as number,
|
|
channelsCount: null as number,
|
|
videosCount: null as number,
|
|
playlistsCount: null as number,
|
|
|
|
currentPage: 1,
|
|
pages: [],
|
|
resultsPerVideosPage: 10,
|
|
resultsPerChannelsPage: 3,
|
|
resultsPerPlaylistsPage: 3,
|
|
|
|
activeFilters: 0,
|
|
displayFilters: false,
|
|
oldQuery: ''
|
|
}
|
|
},
|
|
|
|
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()
|
|
}
|
|
},
|
|
|
|
async 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
|
|
})
|
|
})
|
|
|
|
const config = await getConfig()
|
|
|
|
this.indexName = config.searchInstanceName
|
|
|
|
if (Object.keys(this.$route.query).length !== 0) {
|
|
this.loadUrl()
|
|
this.doSearch()
|
|
}
|
|
},
|
|
|
|
methods: {
|
|
|
|
async doSearch () {
|
|
this.results = []
|
|
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.channelsCount = channelsResult.total
|
|
this.videosCount = videosResult.total
|
|
this.playlistsCount = playlistsResult.total
|
|
|
|
this.resultsCount = videosResult.total + channelsResult.total + playlistsResult.total
|
|
|
|
this.results = channelsResult.data
|
|
this.results = this.results.concat(playlistsResult.data)
|
|
this.results = this.results.concat(videosResult.data)
|
|
|
|
this.buildPages()
|
|
this.searched = true
|
|
} catch (err) {
|
|
console.error('Cannot do search.', err)
|
|
} finally {
|
|
this.searched = true
|
|
Nprogress.done()
|
|
}
|
|
},
|
|
|
|
isVideo (result: EnhancedVideo | EnhancedVideoChannel | EnhancedPlaylist): result is EnhancedVideo {
|
|
if ((result as EnhancedVideo).language) return true
|
|
|
|
return false
|
|
},
|
|
|
|
isPlaylist (result: EnhancedVideo | EnhancedVideoChannel | EnhancedPlaylist): result is EnhancedPlaylist {
|
|
if ((result as EnhancedPlaylist).videosLength !== undefined) return true
|
|
|
|
return false
|
|
},
|
|
|
|
isChannel (result: EnhancedVideo | EnhancedVideoChannel | EnhancedPlaylist): result is EnhancedVideoChannel {
|
|
if ((result as EnhancedVideoChannel).followingCount !== undefined) return true
|
|
|
|
return false
|
|
},
|
|
|
|
getResultKey (result: EnhancedVideo | EnhancedVideoChannel | EnhancedPlaylist) {
|
|
if (this.isVideo(result)) return (result as EnhancedVideo).uuid
|
|
if (this.isChannel(result)) return result.id + (result as EnhancedVideoChannel).host
|
|
if (this.isPlaylist(result)) return (result as EnhancedPlaylist).uuid
|
|
|
|
throw new Error('Unknown result')
|
|
},
|
|
|
|
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 (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 { start, count } = pageToAPIParams(this.currentPage, this.resultsPerVideosPage)
|
|
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 { start, count } = pageToAPIParams(this.currentPage, this.resultsPerChannelsPage)
|
|
|
|
return {
|
|
search: query.search,
|
|
host: query.host || undefined,
|
|
start,
|
|
sort: query.sort,
|
|
count
|
|
} as VideoChannelsSearchQuery
|
|
},
|
|
|
|
buildPlaylistSearchQuery () {
|
|
const query = this.$route.query as SearchUrl
|
|
const { start, count } = pageToAPIParams(this.currentPage, this.resultsPerChannelsPage)
|
|
|
|
return {
|
|
search: query.search,
|
|
host: query.host || undefined,
|
|
start,
|
|
sort: query.sort,
|
|
count
|
|
} as PlaylistsSearchQuery
|
|
},
|
|
|
|
searchVideos (): Promise<ResultList<EnhancedVideo>> {
|
|
const query = this.buildVideoSearchQuery()
|
|
|
|
return searchVideos(query)
|
|
},
|
|
|
|
searchChannels (): Promise<ResultList<EnhancedVideoChannel>> {
|
|
if (this.isChannelOrPlaylistSearchDisabled()) {
|
|
return Promise.resolve({ data: [], total: 0 })
|
|
}
|
|
|
|
const query = this.buildChannelSearchQuery()
|
|
|
|
return searchVideoChannels(query)
|
|
},
|
|
|
|
searchPlaylists (): Promise<ResultList<EnhancedPlaylist>> {
|
|
if (this.isChannelOrPlaylistSearchDisabled()) {
|
|
return Promise.resolve({ data: [], total: 0 })
|
|
}
|
|
|
|
const query = this.buildChannelSearchQuery()
|
|
|
|
return searchVideoPlaylists(query)
|
|
},
|
|
|
|
getChannelsMaxPage () {
|
|
return Math.ceil(this.channelsCount / this.resultsPerChannelsPage)
|
|
},
|
|
|
|
getPlaylistsMaxPage () {
|
|
return Math.ceil(this.playlistsCount / this.resultsPerPlaylistsPage)
|
|
},
|
|
|
|
getVideosMaxPage () {
|
|
return Math.ceil(this.videosCount / this.resultsPerVideosPage)
|
|
},
|
|
|
|
getMaxPage () {
|
|
// Limit to 10 pages
|
|
return Math.min(10, Math.max(this.getPlaylistsMaxPage(), this.getChannelsMaxPage(), this.getVideosMaxPage()))
|
|
},
|
|
|
|
buildPages () {
|
|
this.pages = []
|
|
|
|
for (let i = 1; i <= this.getMaxPage(); i++) {
|
|
this.pages.push(i)
|
|
}
|
|
},
|
|
|
|
toggleFilters () {
|
|
this.displayFilters = !this.displayFilters
|
|
},
|
|
|
|
isChannelOrPlaylistSearchDisabled () {
|
|
const query = this.$route.query as SearchUrl
|
|
|
|
// We can search against host for playlists and channels
|
|
if (query.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;
|
|
}
|
|
|
|
</style>
|