sepia-search-motore-di-rice.../client/src/views/Search.vue

818 lines
25 KiB
Vue

<template>
<div>
<my-header v-bind:indexName="indexName"></my-header>
<main>
<h3 v-bind:class="{ 'none-opacity': !instancesCount }">
Search for your favorite videos and channels on {{instancesCount}} PeerTube websites listed on <strong>{{indexName}}</strong>!
</h3>
<div id="search-anchor" class="search-container">
<input placeholder="Keyword, channel, video, etc." autofocus v-on:keyup.enter="scrollToSearchInput(); doNewSearch()" type="text" v-model="formSearch" name="search-text" />
<button v-on:click="scrollToSearchInput(); doNewSearch()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-search">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
Go!
</button>
</div>
<search-warning class="search-warning" v-bind:indexName="indexName" v-bind:highlight="!searchDone"></search-warning>
<div class="results" v-if="searchDone">
<div class="filters" v-if="searchDone && searchedTerm">
<div class="button-rows">
<button class="filters-button peertube-button" v-on:click="toggleFilters()">
Filters
<span v-if="countActiveFilters()">({{ countActiveFilters() }} active)</span>
<div class="arrow-down"></div>
</button>
<div class="sort-select">
<label for="sort">Sort by:</label>
<div class="sort-select select-container">
<select id="sort" name="sort" v-model="formSort">
<option value="-match">Best match</option>
<option value="-publishedAt">Most recent</option>
<option value="publishedAt">Least recent</option>
</select>
</div>
</div>
</div>
<form v-if="displayFilters" class="filters-content">
<div class="form-group">
<div class="radio-label label-container">
<label>Display sensitive content</label>
<button class="reset-button" v-on:click="resetField('nsfw')" v-if="formNSFW !== undefined">
Reset
</button>
</div>
<div class="radio-container">
<input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" v-model="formNSFW">
<label for="sensitiveContentYes" class="radio">Yes</label>
</div>
<div class="radio-container">
<input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" v-model="formNSFW">
<label for="sensitiveContentNo" class="radio">No</label>
</div>
</div>
<div class="form-group">
<div class="radio-label label-container">
<label>Published date</label>
<button class="reset-button" v-on:click="resetField('publishedDateRange')" v-if="formPublishedDateRange !== undefined">
Reset
</button>
</div>
<div class="radio-container" v-for="date in publishedDateRanges" :key="date.id">
<input type="radio" name="publishedDateRange" v-bind:id="date.id" v-bind:value="date.id" v-model="formPublishedDateRange">
<label v-bind:for="date.id" class="radio">{{ date.label }}</label>
</div>
</div>
<div class="form-group">
<div class="radio-label label-container">
<label>Duration</label>
<button class="reset-button" v-on:click="resetField('durationRange')" v-if="formDurationRange !== undefined">
Reset
</button>
</div>
<div class="radio-container" v-for="duration in durationRanges" :key="duration.id">
<input type="radio" name="durationRange" v-bind:id="duration.id" v-bind:value="duration.id" v-model="formDurationRange">
<label v-bind:for="duration.id" class="radio">{{ duration.label }}</label>
</div>
</div>
<div class="form-group">
<label for="category">Category</label>
<button class="reset-button" v-on:click="resetField('categoryOneOf')" v-if="formCategoryOneOf !== undefined">
Reset
</button>
<div class="select-container">
<select id="category" name="category" v-model="formCategoryOneOf">
<option v-bind:value="undefined">Display all categories</option>
<option v-for="category in videoCategories" v-bind:value="category.id" :key="category.id">{{ category.label }}</option>
</select>
</div>
</div>
<div class="form-group">
<label for="licence">Licence</label>
<button class="reset-button" v-on:click="resetField('licenceOneOf')" v-if="formLicenceOneOf !== undefined">
Reset
</button>
<div class="select-container">
<select id="licence" name="licence" v-model="formLicenceOneOf">
<option v-bind:value="undefined">Display all licenses</option>
<option v-for="licence in videoLicences" v-bind:value="licence.id" :key="licence.id">{{ licence.label }}</option>
</select>
</div>
</div>
<div class="form-group">
<label for="language">Language</label>
<button class="reset-button" v-on:click="resetField('languageOneOf')" v-if="formLanguageOneOf !== undefined">
Reset
</button>
<div class="select-container">
<select id="language" name="language" v-model="formLanguageOneOf">
<option v-bind:value="undefined">Display all languages</option>
<option v-for="language in videoLanguages" v-bind:value="language.id" :key="language.id">{{ language.label }}</option>
</select>
</div>
</div>
<div class="form-group">
<label for="tagsAllOf">All of these tags</label>
<button class="reset-button" v-on:click="resetField('tagsAllOf')" v-if="formTagsAllOf.length !== 0">
Reset
</button>
<vue-tags-input @tags-changed="newTags => formTagsAllOf = newTags" v-model="formTagAllOf" :tags="formTagsAllOf" />
</div>
<div class="form-group">
<label for="tagsOneOf">One of these tags</label>
<button class="reset-button" v-on:click="resetField('tagsOneOf')" v-if="formTagsOneOf.length !== 0">
Reset
</button>
<vue-tags-input @tags-changed="newTags => formTagsOneOf = newTags" v-model="formTagOneOf" :tags="formTagsOneOf" />
</div>
<div class="button-block">
<input class="peertube-button" type="button" value="Apply filters" v-on:click="scrollToResults(); doNewSearch()" />
</div>
</form>
</div>
<div id="results-anchor" class="results-summary" v-if="(formSearch && resultsCount === 0) || (resultsCount !== null && resultsCount !== 0)">
<span v-if="formSearch && resultsCount === 0">No results found for </span>
<span v-if="resultsCount !== null && resultsCount !== 0">{{resultsCount}} results found for </span>
<strong>{{ searchedTerm }} <span v-if="countActiveFilters() > 0">with {{ countActiveFilters() }} active filters</span></strong>
on {{instancesCount}} indexed PeerTube websites
</div>
<div v-for="result in results" :key="getResultKey(result)">
<video-result v-if="isVideo(result)" v-bind:video="result"></video-result>
<channel-result v-else v-bind:channel="result"></channel-result>
</div>
<pagination v-bind:maxPage="getMaxPage()" v-bind:searchDone="searchDone" v-bind:currentPage="currentPage" v-bind:pages="pages"></pagination>
</div>
</main>
</div>
</template>
<style lang="scss">
@import '../scss/_variables';
main {
margin: auto;
}
h3 {
max-width: 600px;
text-align: center;
margin: auto;
font-weight: normal;
font-size: 16px;
@media screen and (max-width: $small-screen) {
font-size: 14px;
}
}
.search-container {
background-color: #fff;
border-radius: 2px;
position: relative;
max-width: 500px;
height: 45px;
margin: auto;
margin-top: 30px;
input[type=text] {
background-color: transparent;
outline: none;
height: 35px;
font-size: 15px;
border: 0;
width: 100%;
height: 100%;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
padding: 0 20px;
}
button {
border-radius: 2px;
cursor: pointer;
position: absolute;
right: 0;
background-color: $orange-darken;
border: 0;
color: #FFF;
height: 100%;
outline: 0;
font-size: 15px;
padding: 0 15px 0 10px;
display: inline-flex;
align-items: center;
svg {
margin-right: 10px;
}
&:hover {
background-color: $orange;
}
}
}
.search-warning {
margin: 50px 0;
}
.results-summary,
.no-results {
margin-top: 50px;
}
.results {
border-top: 1px solid rgba(0, 0, 0, 0.1);
padding-top: 20px;
}
.filters-content {
display: flex;
flex-wrap: wrap;
margin-top: 20px;
.form-group:nth-child(2n-1) {
padding-right: 20px;
}
.form-group {
width: 50%;
min-height: 80px;
display: inline-block;
margin: 10px 0;
font-size: 14px;
}
@media screen and (max-width: $small-view) {
.form-group {
width: 100%;
padding-right: 0;
}
}
.form-group > label,
.label-container label {
font-weight: $font-semibold;
}
.form-group > label,
.label-container {
display: inline-block;
margin-bottom: 5px;
}
.radio-container {
display: inline-block;
margin: 0 10px 5px 0;
}
.button-block {
width: 100%;
text-align: right;
}
.radio-label {
display: block;
}
.reset-button {
background: none;
border: none;
font-weight: 600;
font-size: 11px;
opacity: 0.7;
margin-left: 5px;
cursor: pointer;
}
}
.filters-button {
position: relative;
padding-right: 25px;
.arrow-down {
$size: 4px;
position: absolute;
right: 0;
top: 50%;
margin-top: -$size/2;
margin-right: 10px;
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;
.sort-select {
display: flex;
label {
color: $grey;
font-size: 14px;
min-width: fit-content;
margin: auto 5px auto 0;
}
.select-container {
max-width: 150px;
}
}
}
</style>
<script lang="ts">
import Vue 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 { searchVideos, searchVideoChannels } from '../shared/search'
import { getConfig } from '../shared/config'
import { pageToAPIParams, durationRangeToAPIParams, publishedDateRangeToAPIParams, extractTagsFromQuery } from '../shared/utils'
import { SearchUrl } from '../models'
import { EnhancedVideo } from '../../../server/types/video.model'
import { EnhancedVideoChannel } from '../../../server/types/channel.model'
import VueTagsInput from '@johmun/vue-tags-input'
import Pagination from '../components/Pagination.vue'
import { VideosSearchQuery, VideoChannelsSearchQuery, ResultList } from '../../../PeerTube/shared/models'
export default Vue.extend({
components: {
'my-header': Header,
'search-warning': SearchWarning,
'video-result': VideoResult,
'channel-result': ChannelResult,
'vue-tags-input': VueTagsInput,
'pagination': Pagination
},
data () {
return {
searchDone: false,
indexName: '',
instancesCount: null as number,
results: [] as (EnhancedVideo | EnhancedVideoChannel)[],
resultsCount: null as number,
channelsCount: null as number,
videosCount: null as number,
searchedTerm: '',
currentPage: 1,
pages: [],
resultsPerVideosPage: 10,
resultsPerChannelsPage: 5,
displayFilters: false,
oldQuery: '',
formSearch: '',
formSort: '-match',
formNSFW: undefined,
formPublishedDateRange: undefined,
formDurationRange: undefined,
formCategoryOneOf: undefined,
formLicenceOneOf: undefined,
formLanguageOneOf: undefined,
formTagAllOf: '',
formTagOneOf: '',
formTagsAllOf: [],
formTagsOneOf: []
}
},
async mounted () {
const config = await getConfig()
this.instancesCount = config.indexedHostsCount
this.indexName = config.searchInstanceName
this.loadUrl()
},
watch: {
$route(to, from) {
if (!this.searchDone) return
this.loadUrl()
},
formSort () {
this.doSearch()
}
},
computed: {
publishedDateRanges () {
return [
{
id: 'any_published_date',
label: 'Any'
},
{
id: 'today',
label: 'Today'
},
{
id: 'last_7days',
label: 'Last 7 days'
},
{
id: 'last_30days',
label: 'Last 30 days'
},
{
id: 'last_365days',
label: 'Last 365 days'
}
]
},
durationRanges () {
return [
{
id: 'any_duration',
label: 'Any'
},
{
id: 'short',
label: 'Short (< 4 min)'
},
{
id: 'medium',
label: 'Medium (4-10 min)'
},
{
id: 'long',
label: 'Long (> 10 min)'
}
]
},
sorts () {
return [
{
id: '-match',
label: 'Relevance'
},
{
id: '-publishedAt',
label: 'Publish date'
},
{
id: '-views',
label: 'Views'
}
]
},
videoCategories () {
return [
{ id: '1', label: this.$gettext('Music') },
{ id: '2', label: this.$gettext('Films') },
{ id: '3', label: this.$gettext('Vehicles') },
{ id: '4', label: this.$gettext('Art') },
{ id: '5', label: this.$gettext('Sports') },
{ id: '6', label: this.$gettext('Travels') },
{ id: '7', label: this.$gettext('Gaming') },
{ id: '8', label: this.$gettext('People') },
{ id: '9', label: this.$gettext('Comedy') },
{ id: '10', label: this.$gettext('Entertainment') },
{ id: '11', label: this.$gettext('News & Politics') },
{ id: '12', label: this.$gettext('How To') },
{ id: '13', label: this.$gettext('Education') },
{ id: '14', label: this.$gettext('Activism') },
{ id: '15', label: this.$gettext('Science & Technology') },
{ id: '16', label: this.$gettext('Animals') },
{ id: '17', label: this.$gettext('Kids') },
{ id: '18', label: this.$gettext('Food') }
]
},
videoLicences () {
return [
{ id: '1', label: this.$gettext('Attribution') },
{ id: '2', label: this.$gettext('Attribution - Share Alike') },
{ id: '3', label: this.$gettext('Attribution - No Derivatives') },
{ id: '4', label: this.$gettext('Attribution - Non Commercial') },
{ id: '5', label: this.$gettext('Attribution - Non Commercial - Share Alike') },
{ id: '6', label: this.$gettext('Attribution - Non Commercial - No Derivatives') },
{ id: '7', label: this.$gettext('Public Domain Dedication') }
]
},
videoLanguages () {
return [
{ id: 'en', label: this.$gettext('English') },
{ id: 'fr', label: this.$gettext('Français') },
{ id: 'ja', label: this.$gettext('日本語') },
{ id: 'eu', label: this.$gettext('Euskara') },
{ id: 'ca', label: this.$gettext('Català') },
{ id: 'cs', label: this.$gettext('Čeština') },
{ id: 'eo', label: this.$gettext('Esperanto') },
{ id: 'el', label: this.$gettext('ελληνικά') },
{ id: 'de', label: this.$gettext('Deutsch') },
{ id: 'it', label: this.$gettext('Italiano') },
{ id: 'nl', label: this.$gettext('Nederlands') },
{ id: 'es', label: this.$gettext('Español') },
{ id: 'oc', label: this.$gettext('Occitan') },
{ id: 'gd', label: this.$gettext('Gàidhlig') },
{ id: 'zh', label: this.$gettext('简体中文(中国)') },
{ id: 'pt', label: this.$gettext('Português (Portugal)') },
{ id: 'sv', label: this.$gettext('svenska') },
{ id: 'pl', label: this.$gettext('Polski') },
{ id: 'fi', label: this.$gettext('suomi') },
{ id: 'ru', label: this.$gettext('русский') }
]
}
},
methods: {
doNewSearch (updateUrl = true) {
this.currentPage = 1
this.channelsCount = null
this.videosCount = null
this.resultsCount = null
return this.doSearch(updateUrl)
},
async doSearch (updateUrl = true) {
if (updateUrl) this.updateUrl()
this.results = []
const [ videosResult, channelsResult ] = await Promise.all([
this.searchVideos(),
this.searchChannels()
])
this.channelsCount = channelsResult.total
this.videosCount = videosResult.total
this.resultsCount = videosResult.total + channelsResult.total
this.results = channelsResult.data.concat(videosResult.data)
this.results.sort((r1, r2) => {
if (r1.score < r2.score) return -1
else if (r1.score === r2.score) return 0
return -1
})
this.buildPages()
this.searchDone = true
this.searchedTerm = this.formSearch
},
isVideo (result: EnhancedVideo | EnhancedVideoChannel): result is EnhancedVideo {
if ((result as EnhancedVideo).language) return true
return false
},
getResultKey (result: EnhancedVideo | EnhancedVideoChannel) {
if (this.isVideo(result)) return (result as EnhancedVideo).uuid
return result.id + (result as EnhancedVideoChannel).host
},
updateUrl () {
const query: SearchUrl = {
search: this.formSearch,
sort: this.formSort,
nsfw: this.formNSFW,
publishedDateRange: this.formPublishedDateRange,
durationRange: this.formDurationRange,
categoryOneOf: this.formCategoryOneOf,
licenceOneOf: this.formLicenceOneOf,
languageOneOf: this.formLanguageOneOf,
tagsAllOf: this.formTagsAllOf.map(t => t.text),
tagsOneOf: this.formTagsOneOf.map(t => t.text)
}
this.$router.push({ path: '/search', query })
},
loadUrl () {
const query = this.$route.query as SearchUrl & { page: number }
if (Object.keys(query).length === 0) return
if (query.search) this.formSearch = query.search
if (query.nsfw) this.formNSFW = query.nsfw
if (query.publishedDateRange) this.formPublishedDateRange = query.publishedDateRange
if (query.durationRange) this.formDurationRange = query.durationRange
if (query.categoryOneOf) this.formCategoryOneOf = query.categoryOneOf
if (query.licenceOneOf) this.formLicenceOneOf = query.licenceOneOf
if (query.languageOneOf) this.formLanguageOneOf = query.languageOneOf
if (query.tagsAllOf) this.formTagsAllOf = extractTagsFromQuery(query.tagsAllOf)
if (query.tagsOneOf) this.formTagsOneOf = extractTagsFromQuery(query.tagsOneOf)
if (query.sort) this.formSort = query.sort
if (query.page && this.currentPage !== query.page) {
this.currentPage = parseInt(query.page + '')
this.scrollToResults()
}
this.doSearch(false)
},
buildVideoSearchQuery () {
const { start, count } = pageToAPIParams(this.currentPage, this.resultsPerVideosPage)
const { durationMin, durationMax } = durationRangeToAPIParams(this.formDurationRange)
const { startDate, endDate } = publishedDateRangeToAPIParams(this.formPublishedDateRange)
return {
search: this.formSearch,
durationMin,
durationMax,
startDate,
endDate,
nsfw: this.nsfw,
categoryOneOf: this.formCategoryOneOf ? [ this.formCategoryOneOf ] : undefined,
licenceOneOf: this.formLicenceOneOf ? [ this.formLicenceOneOf ] : undefined,
languageOneOf: this.formLanguageOneOf ? [ this.formLanguageOneOf ] : undefined,
tagsOneOf: this.formTagsOneOf.map(t => t.text),
tagsAllOf: this.formTagsAllOf.map(t => t.text),
start,
count,
sort: this.formSort
} as VideosSearchQuery
},
buildChannelSearchQuery () {
const { start, count } = pageToAPIParams(this.currentPage, this.resultsPerChannelsPage)
return {
search: this.formSearch,
start,
count
} as VideoChannelsSearchQuery
},
searchVideos (): Promise<ResultList<EnhancedVideo>> {
if (!this.formSearch) {
return Promise.resolve({ data: [], total: 0 })
}
if (!this.hasStillMoreVideosResults()) {
return Promise.resolve({ data: [], total: this.videosCount })
}
const query = this.buildVideoSearchQuery()
return searchVideos(query)
},
searchChannels (): Promise<ResultList<EnhancedVideoChannel>> {
if (!this.formSearch || this.isChannelSearchDisabled()) {
return Promise.resolve({ data: [], total: 0 })
}
if (!this.hasStillChannelsResult()) {
return Promise.resolve({ data: [], total: this.channelsCount })
}
const query = this.buildChannelSearchQuery()
return searchVideoChannels(query)
},
hasStillChannelsResult () {
// Not searched yet
if (this.channelsCount === null) return true
return this.getChannelsMaxPage() >= this.currentPage
},
hasStillMoreVideosResults () {
// Not searched yet
if (this.videosCount === null) return true
return this.getVideosMaxPage() >= this.currentPage
},
getChannelsMaxPage () {
return Math.ceil(this.channelsCount / this.resultsPerChannelsPage)
},
getVideosMaxPage () {
return Math.ceil(this.videosCount / this.resultsPerVideosPage)
},
getMaxPage () {
// Limit to 10 pages
return Math.min(10, Math.max(this.getChannelsMaxPage(), this.getVideosMaxPage()))
},
buildPages () {
this.pages = []
for (let i = 1; i <= this.getMaxPage(); i++) {
this.pages.push(i)
}
},
toggleFilters () {
this.displayFilters = !this.displayFilters
},
resetField (field: string) {
if (field === 'nsfw') this.formNSFW = undefined
else if (field === 'publishedDateRange') this.formPublishedDateRange = undefined
else if (field === 'durationRange') this.formDurationRange = undefined
else if (field === 'categoryOneOf') this.formCategoryOneOf = undefined
else if (field === 'licenceOneOf') this.formLicenceOneOf = undefined
else if (field === 'languageOneOf') this.formLanguageOneOf = undefined
else if (field === 'tagsAllOf') this.formTagsAllOf = []
else if (field === 'tagsOneOf') this.formTagsOneOf = []
},
countActiveFilters () {
let count = 0
if (this.formNSFW) count++
if (this.formPublishedDateRange) count++
if (this.formDurationRange) count++
if (this.formCategoryOneOf) count++
if (this.formLicenceOneOf) count++
if (this.formLanguageOneOf) count++
if (this.formTagsAllOf && this.formTagsAllOf.length !== 0) count++
if (this.formTagsOneOf && this.formTagsOneOf.length !== 0) count++
return count
},
isChannelSearchDisabled () {
return this.countActiveFilters() > 0
},
scrollToResults () {
const anchor = document.getElementById('results-anchor')
if (anchor) anchor.scrollIntoView()
},
scrollToSearchInput () {
const anchor = document.getElementById('search-anchor')
if (anchor) anchor.scrollIntoView()
}
}
})
</script>