Add client

This commit is contained in:
Chocobozzz 2020-08-27 14:44:21 +02:00
parent 83cec31a6c
commit c6d844d4f7
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
46 changed files with 9910 additions and 16 deletions

@ -1 +1 @@
Subproject commit 3521ab8fc01da85fa804439ca6e297e6fb364c58
Subproject commit b2c76204f9d2900b9fd58e6618e343059821d601

2
client/.env Normal file
View File

@ -0,0 +1,2 @@
VUE_APP_TITLE=PeerTube Global Search
VUE_APP_FOOTER=Made with ❤️ by Framasoft

2
client/.env.development Normal file
View File

@ -0,0 +1,2 @@
VUE_APP_API_URL=http://localhost:3234
#VUE_APP_API_URL=https://search.joinpeertube.org

0
client/.env.production Normal file
View File

5
client/.postcssrc Normal file
View File

@ -0,0 +1,5 @@
{
"plugins": {
"autoprefixer": {}
}
}

64
client/Makefile Normal file
View File

@ -0,0 +1,64 @@
# From https://raw.githubusercontent.com/Polyconseil/vue-gettext/master/Makefile
# On OSX the PATH variable isn't exported unless "SHELL" is also set, see: http://stackoverflow.com/a/25506676
SHELL = /bin/bash
NODE_BINDIR = ./node_modules/.bin
export PATH := $(NODE_BINDIR):$(PATH)
LOGNAME ?= $(shell logname)
# adding the name of the user's login name to the template file, so that
# on a multi-user system several users can run this without interference
TEMPLATE_POT ?= /tmp/template-$(LOGNAME).pot
# Where to find input files (it can be multiple paths).
INPUT_FILES = ./src
# Where to write the files generated by this makefile.
OUTPUT_DIR = ./src
# Available locales for the app.
LOCALES = en_US fr_FR
# Name of the generated .po files for each available locale.
LOCALE_FILES ?= $(patsubst %,$(OUTPUT_DIR)/locale/%/LC_MESSAGES/app.po,$(LOCALES))
GETTEXT_SOURCES ?= $(shell find $(INPUT_FILES) -name '*.jade' -o -name '*.html' -o -name '*.js' -o -name '*.vue' 2> /dev/null)
# Makefile Targets
.PHONY: clean makemessages translations all
all:
@echo choose a target from: clean makemessages translations
clean:
rm -rf $(TEMPLATE_POT)
makemessages: $(TEMPLATE_POT)
translations: $(LOCALE_FILES)
mkdir -p $(OUTPUT_DIR)/translations
@for lang in $(LOCALES); do \
gettext-compile --output $(OUTPUT_DIR)/translations/$$lang.json $(OUTPUT_DIR)/locale/$$lang/LC_MESSAGES/app.po; \
done;
# Create a main .pot template, then generate .po files for each available language.
# Thanx to Systematic: https://github.com/Polyconseil/systematic/blob/866d5a/mk/main.mk#L167-L183
$(TEMPLATE_POT): $(GETTEXT_SOURCES)
# `dir` is a Makefile built-in expansion function which extracts the directory-part of `$@`.
# `$@` is a Makefile automatic variable: the file name of the target of the rule.
# => `mkdir -p /tmp/`
mkdir -p $(dir $@)
# Extract gettext strings from templates files and create a POT dictionary template.
gettext-extract --removeHTMLWhitespaces --quiet --attribute v-translate --output $@ $(GETTEXT_SOURCES)
# Generate .po files for each available language.
@for lang in $(LOCALES); do \
export PO_FILE=$(OUTPUT_DIR)/locale/$$lang/LC_MESSAGES/app.po; \
mkdir -p $$(dirname $$PO_FILE); \
if [ -f $$PO_FILE ]; then \
echo "msgmerge --update $$PO_FILE $@"; \
msgmerge --lang=$$lang --update $$PO_FILE $@ || break ;\
else \
msginit --no-translator --locale=$$lang --input=$@ --output-file=$$PO_FILE || break ; \
msgattrib --no-wrap --no-obsolete -o $$PO_FILE $$PO_FILE || break; \
fi; \
done;

34
client/package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "peertube-search-index-client",
"version": "0.0.1",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --mode development",
"build": "vue-cli-service build --mode production",
"lint": "vue-cli-service lint",
"i18n:update": "git fetch weblate && git merge weblate/master && rm -f src/locale/en_US/LC_MESSAGES/app.po && make clean && make makemessages && make translations"
},
"dependencies": {},
"devDependencies": {
"@johmun/vue-tags-input": "^2.1.0",
"@types/axios": "^0.14.0",
"@vue/cli-plugin-typescript": "^4.5.4",
"@vue/cli-service": "^4.0.5",
"axios": "^0.20.0",
"node-sass": "^4.13.0",
"register-service-worker": "^1.0.0",
"rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-vue": "^6.0.0-beta.10",
"sass-loader": "^10.0.1",
"vue": "^2.6.3",
"vue-gettext": "^2.1.10",
"vue-matomo": "^3.13.5-0",
"vue-router": "^3.1.3",
"vue-template-compiler": "^2.6.3"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

24
client/public/index.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" type="image/png" href="<%= BASE_URL %>img/favicon.png">
<title>PeerTube Global Search</title>
</head>
<body>
<noscript>
<strong>We're sorry but client doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
<div id="footer"></div>
<!-- built files will be auto injected -->
</body>
</html>

27
client/src/App.vue Normal file
View File

@ -0,0 +1,27 @@
<template>
<div>
<router-view id="main-container" class="container"/>
<my-footer></my-footer>
</div>
</template>
<script lang="ts">
import Footer from './components/Footer.vue'
export default {
components: {
'my-footer': Footer
},
data () {
return {
title: process.env.VUE_APP_TITLE
}
}
}
</script>
<style lang="scss">
</style>

View File

@ -0,0 +1,73 @@
<template>
<a v-bind:href="actor.url" target="_blank" class="actor" v-bind:title="linkTitle">
<img v-if="actor.avatar" v-bind:src="actor.avatar.url" alt="">
<strong>{{ actor.displayName }}</strong>
<span class="actor-handle">
{{ actor.name }}@{{actor.host}}
</span>
</a>
</template>
<style lang="scss">
@import '../scss/_variables';
.actor {
font-size: inherit;
color: #000;
text-decoration: none;
&:hover {
text-decoration: underline;
}
img {
object-fit: cover;
border-radius: 50%;
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
margin-right: 5px;
}
.actor-handle {
color: $grey;
margin: 0 5px;
}
strong,
.actor-handle {
vertical-align: super;
}
@media screen and (max-width: $small-view) {
.actor-handle {
display: block;
margin: 0 0 10px 0;
}
}
}
</style>
<script lang="ts">
import Vue, { PropType } from 'vue'
import { AccountSummary, VideoChannelSummary } from '../../../PeerTube/shared/models'
export default Vue.extend({
props: {
actor: Object as PropType<AccountSummary | VideoChannelSummary>,
type: String
},
computed: {
linkTitle () {
if (this.type === 'channel') return 'Go on this channel page'
return 'Go on this account page'
}
}
})
</script>

View File

@ -0,0 +1,70 @@
<template>
<div class="channel root-result">
<div class="avatar">
<img v-if="channel.avatar" v-bind:src="channel.avatar.url" alt="">
<img v-else src="/img/default-avatar.png" alt="">
</div>
<div class="information">
<div class="title-block">
<h5 class="title">{{ channel.displayName }}</h5>
<span class="handle">{{ channel.name }}@{{ channel.host }}</span>
</div>
<div class="additional-information">
<div class="followers-count">{{ channel.followersCount }} followers</div>
</div>
<div class="description">{{ channel.description }}</div>
<div class="button">
<a class="button-link" target="_blank" v-bind:href="channel.url">Discover this channel on {{host}}</a>
</div>
</div>
</div>
</template>
<style lang="scss">
@import '../scss/_variables';
.channel {
.avatar {
width: $thumbnail-width;
min-width: $thumbnail-width;
margin-right: 20px;
display: flex;
justify-content: center;
img {
object-fit: cover;
border-radius: 50%;
width: 110px;
height: 110px;
min-width: 110px;
min-height: 110px;
}
}
}
</style>
<script lang="ts">
import Vue, { PropType } from 'vue'
import { Video, VideoChannel } from '../../../PeerTube/shared/models'
export default Vue.extend({
props: {
channel: Object as PropType<VideoChannel>
},
computed: {
host () {
const url = this.channel.url
return new URL(url as string).host
}
}
})
</script>

View File

@ -0,0 +1,120 @@
<template>
<footer id="main-footer">
<div class="header">
<div class="title">PeerTube</div>
<div class="description">A free software to take back control of your videos</div>
</div>
<div class="columns">
<div>
<div class="subtitle">Open your own videos website with PeerTube!</div>
<a href="">Install PeerTube</a>
<a href="">Why should I have my own PeerTube website?</a>
<a class="all" href=""><strong>Check all guides >></strong></a>
</div>
<div>
<div class="subtitle">Create an account to take back control of your videos</div>
<a href="">Open an account on a PeerTube website</a>
<a href="">Create playlists</a>
<a class="all" href=""><strong>Check all guides >></strong></a>
</div>
</div>
</footer>
</template>
<style lang="scss">
@import '../scss/_variables';
footer {
width: $container-width;
margin: auto;
background-color: $orange-lighten;
padding: 30px 100px;
font-family: monospace;
.header {
margin: 50px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.title {
font-size: 36px;
}
.description {
color: $grey;
}
.columns {
display: flex;
> div {
display: flex;
flex-direction: column;
text-align: center;
}
@media screen and (max-width: $container-width) {
flex-direction: column;
> div {
margin-top: 50px;
}
}
}
.subtitle {
color: $grey;
font-size: 20px;
margin-bottom: 50px;
}
a {
color: $orange;
margin: 0 auto 20px auto;
min-height: 40px;
display: flex;
width: fit-content;
&:hover {
color: $orange-darken;
}
}
@media screen and (max-width: $container-width) {
width: 100%;
}
@media screen and (max-width: $small-view) {
padding: 30px;
.title {
font-size: 20px;
}
.subtitle {
font-size: 16px;
}
}
}
</style>
<script>
import Vue from 'vue'
export default Vue.extend({
})
</script>

View File

@ -0,0 +1,70 @@
<template>
<div>
<header>
<h1>{{indexName}}</h1>
<h4>A video index developed by <a href="https://framasoft.org" target="_blank">Framasoft</a></h4>
<img src="/img/search-home.png" alt="">
</header>
</div>
</template>
<style lang="scss">
@import '../scss/_variables';
header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 30px;
font-family: monospace;
img {
width: 300px;
margin: 30px 0 0 0;
}
h1 {
font-size: 50px;
margin: 0;
text-align: center;
}
h4 {
font-weight: normal;
font-size: 14px;
margin: 0;
text-align: center;
a {
color: #000;
font-weight: $font-semibold;
}
}
@media screen and (max-width: $small-screen) {
h1 {
font-size: 30px;
margin-bottom: 10px;
}
img {
width: 100%;
max-width: 300px;
}
}
}
</style>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
props: {
indexName: String
}
})
</script>

View File

@ -0,0 +1,82 @@
<template>
<div class="pagination" v-if="searchDone">
<router-link class="previous" v-bind:class="{ 'none-opacity': currentPage === 1 }" :to="{ query: buildPageUrlQuery(currentPage - 1) }">Previous page</router-link>
<div class="pages">
<template v-for="page in pages">
<router-link v-if="page !== currentPage" class="go-to-page" :to="{ query: buildPageUrlQuery(page) }" :key="page">
{{ page }}
</router-link>
<div v-else class="current" :key="page">{{ page }}</div>
</template>
</div>
<router-link class="next" v-bind:class="{ 'none-opacity': currentPage >= maxPage }" :to="{ query: buildPageUrlQuery(+currentPage + 1) }">Next page</router-link>
</div>
</template>
<style lang="scss">
@import '../scss/_variables';
.pagination {
margin-top: 50px;
display: flex;
justify-content: center;
.pages {
display: flex;
* {
margin: 0 5px;
}
}
a {
color: #4285f5;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.previous {
margin-right: 10px;
}
.next {
margin-left: 10px;
}
@media screen and (max-width: $small-view) {
font-size: 14px;
}
}
</style>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
props: {
maxPage: Number,
searchDone: Boolean,
currentPage: Number,
pages: Array as () => number[]
},
methods: {
buildPageUrlQuery (page: number) {
const query = this.$route.query
return Object.assign({}, query, { page })
},
}
})
</script>

View File

@ -0,0 +1,34 @@
<template>
<div class="block" v-bind:class="{ highlight: highlight }">
<strong>{{indexName}}</strong> displays videos and channels that match your search but is not the publisher, nor the owner.
If you notice any problems with a video, report it to the corresponding administrator on the PeerTube website.
</div>
</template>
<style scoped lang="scss">
@import '../scss/_variables';
.block {
font-style: italic;
color: $orange;
}
.highlight {
color: #fff;
background-color: $orange-darken;
font-style: normal;
padding: 20px;
}
</style>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
props: {
indexName: String,
highlight: Boolean
}
})
</script>

View File

@ -0,0 +1,136 @@
<template>
<div class="video root-result">
<div class="thumbnail">
<div class="img">
<img v-bind:src="getVideoThumbnailUrl()" alt="">
<span class="duration">{{ formattedDuration }}</span>
</div>
</div>
<div class="information">
<h5 class="title">{{ video.name }}</h5>
<div class="description">{{ video.description }}</div>
<div class="metadatas">
<div class="by-account">
<label>Created by</label>
<actor-miniature type="account" v-bind:actor="video.account"></actor-miniature>
</div>
<div class="by-channel">
<label>In</label>
<actor-miniature type="channel" v-bind:actor="video.channel"></actor-miniature>
</div>
<div class="language">
<label>Language</label>
<div class="value">{{ video.language.label }}</div>
</div>
<div class="tags" v-if="video.tags && video.tags.length !== 0">
<label>Tags</label>
<div class="value">{{ video.tags.join(', ') }}</div>
</div>
</div>
<div class="button">
<a class="button-link" target="_blank" v-bind:href="video.url">Watch the video on {{host}}</a>
</div>
</div>
</div>
</template>
<style lang="scss">
@import '../scss/_variables';
.video {
.thumbnail {
margin-right: 20px;
--thumbnail-width: #{$thumbnail-width};
--thumbnail-height: #{$thumbnail-height};
// For the duration overlay
.img {
position: relative;
}
img {
width: var(--thumbnail-width);
height: var(--thumbnail-height);
border-radius: 3px;
}
.duration {
position: absolute;
padding: 2px 5px;
right: 5px;
bottom: 5px;
display: inline-block;
background-color: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 11px;
border-radius: 3px;
}
@media screen and (max-width: $small-view) {
--thumbnail-width: calc(100% + 10px);
--thumbnail-height: auto;
img {
border-radius: 0;
}
}
}
}
</style>
<script lang="ts">
import Vue, { PropType } from 'vue'
import ActorMiniature from './ActorMiniature.vue'
import { Video } from '../../../PeerTube/shared/models'
import { durationToString } from '../shared/utils'
export default Vue.extend({
components: {
'actor-miniature': ActorMiniature
},
props: {
video: Object as PropType<Video>
},
computed: {
host () {
const url = this.video.url
return new URL(url as string).host
},
formattedDuration () {
return durationToString(this.video.duration)
},
windowWidth () {
return window.innerWidth
}
},
methods: {
getVideoThumbnailUrl () {
if (this.windowWidth >= 900) {
return this.video.thumbnailUrl
}
return this.video.previewUrl
}
}
})
</script>

154
client/src/main.ts Normal file
View File

@ -0,0 +1,154 @@
import './scss/main.scss'
import Vue from 'vue'
import GetTextPlugin from 'vue-gettext'
import VueMatomo from 'vue-matomo'
import App from './App.vue'
import router from './router'
Vue.config.productionTip = process.env.NODE_ENV === 'production'
// ############# I18N ##############
const availableLanguages = {
'en_US': 'English',
'fr_FR': 'Français'
}
const aliasesLanguages = {
'en': 'en_US',
'fr': 'fr_FR',
'pt': 'pt_BR'
}
const allLocales = Object.keys(availableLanguages).concat(Object.keys(aliasesLanguages))
const defaultLanguage = 'en_US'
let currentLanguage = defaultLanguage
const basePath = process.env.BASE_URL
const startRegexp = new RegExp('^' + basePath)
const paths = window.location.pathname
.replace(startRegexp, '')
.split('/')
const localePath = paths.length !== 0 ? paths[0] : ''
const languageFromLocalStorage = localStorage.getItem('language')
if (allLocales.includes(localePath)) {
currentLanguage = aliasesLanguages[localePath] ? aliasesLanguages[localePath] : localePath
localStorage.setItem('language', currentLanguage)
} else if (languageFromLocalStorage) {
currentLanguage = languageFromLocalStorage
} else {
const navigatorLanguage = (window.navigator as any).userLanguage || window.navigator.language
const snakeCaseLanguage = navigatorLanguage.replace('-', '_')
currentLanguage = aliasesLanguages[snakeCaseLanguage] ? aliasesLanguages[snakeCaseLanguage] : snakeCaseLanguage
}
Vue.filter('translate', value => {
return value ? Vue.prototype.$gettext(value.toString()) : ''
})
const p = buildTranslationsPromise(defaultLanguage, currentLanguage)
p.catch(err => {
console.error('Cannot load translations.', err)
return { default: {} }
}).then(translations => {
Vue.use(GetTextPlugin, {
translations,
availableLanguages,
defaultLanguage: 'en_US',
silent: process.env.NODE_ENV === 'production'
})
Vue.config.language = currentLanguage
// Stats Matomo
if (!(navigator.doNotTrack === 'yes' ||
navigator.doNotTrack === '1' ||
window.doNotTrack === '1')
) {
Vue.use(VueMatomo, {
// Configure your matomo server and site
host: 'https://stats.framasoft.org/',
siteId: 77,
// Enables automatically registering pageviews on the router
router,
// Require consent before sending tracking information to matomo
// Default: false
requireConsent: false,
// Whether to track the initial page view
// Default: true
trackInitialView: true,
// Changes the default .js and .php endpoint's filename
// Default: 'piwik'
trackerFileName: 'p',
enableLinkTracking: true
})
const _paq = []
// CNIL conformity
_paq.push([ function piwikCNIL () {
const self = this
function getOriginalVisitorCookieTimeout () {
const now = new Date()
const nowTs = Math.round(now.getTime() / 1000)
const visitorInfo = self.getVisitorInfo()
const createTs = parseInt(visitorInfo[2], 10)
const cookieTimeout = 33696000 // 13 months in seconds
return (createTs + cookieTimeout) - nowTs
}
this.setVisitorCookieTimeout(getOriginalVisitorCookieTimeout())
} ])
}
new Vue({
router,
render: h => h(App),
components: {
}
}).$mount('#app')
})
function buildTranslationsPromise (defaultLanguage, currentLanguage) {
const translations = {}
// No need to translate anything
if (currentLanguage === defaultLanguage) return Promise.resolve(translations)
// Fetch translations from server
const fromRemote = import('../translations/' + currentLanguage + '.json')
.then(module => {
const remoteTranslations = module.default
try {
localStorage.setItem('translations-v1-' + currentLanguage, JSON.stringify(remoteTranslations))
} catch (err) {
console.error('Cannot save translations in local storage.', err)
}
return Object.assign(translations, remoteTranslations)
})
// If we have a cache, try to
const fromLocalStorage = localStorage.getItem('translations-v1-' + currentLanguage)
if (fromLocalStorage) {
try {
Object.assign(translations, JSON.parse(fromLocalStorage))
return Promise.resolve(translations)
} catch (err) {
console.error('Cannot parse translations from local storage.', err)
}
}
return fromRemote
}

View File

@ -0,0 +1 @@
export * from './search-url.model'

View File

@ -0,0 +1,14 @@
export interface SearchUrl {
[id: string]: string | undefined | number[] | string[]
search?: string
nsfw?: string
publishedDateRange?: string
durationRange?: string
categoryOneOf?: number[]
licenceOneOf?: number[]
languageOneOf?: string[]
tagsAllOf?: string[]
tagsOneOf?: string[]
}

20
client/src/router.ts Normal file
View File

@ -0,0 +1,20 @@
import Vue from 'vue'
import Router from 'vue-router'
import Search from '@/views/Search.vue'
Vue.use(Router)
export default new Router({
mode: 'history',
routes: [
{
path: '/',
component: Search
},
{
path: '/search',
component: Search
}
]
})

View File

@ -0,0 +1,22 @@
$font-semibold: 600;
$orange: #f67e08;
$orange-darken: #f1680d;
$orange-lighten: #ffefde;
$grey: #858383;
$responsive-screen: 992px;
$small-screen: 700px;
$primary: $orange;
$secondary: $grey;
$thumbnail-width: 223px;
$thumbnail-height: 122px;
$small-font-size: 12px;
$container-width: 1024px;
$small-view: 900px;
$input-height: 30px;

212
client/src/scss/main.scss Normal file
View File

@ -0,0 +1,212 @@
@import "_variables";
* {
box-sizing: border-box;
}
body {
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
font-size: 16px;
background-color: #ffad5c;
margin: 0;
@media screen and (max-width: $container-width) {
font-size: 14px;
}
}
.container {
padding-top: 0;
margin-top: 0;
width: $container-width;
background-color: #fff;
margin: auto;
padding: 50px 80px;
@media screen and (max-width: $container-width) {
width: 100%;
padding: 50px 10px;
}
@media screen and (max-width: $small-screen) {
padding: 20px 10px;
}
}
.button-link,
.peertube-button {
background-color: $orange-darken;
color: #fff;
font-weight: $font-semibold;
border-radius: 5px;
padding: 7px 20px;
font-size: 13px;
text-decoration: none;
text-align: center;
outline: none;
cursor: pointer;
&:hover {
background-color: $orange;
}
}
.peertube-button {
font-weight: 600;
font-size: 15px;
height: 30px;
line-height: 30px;
padding: 0 17px 0 13px;
border: none;
}
.none-opacity {
opacity: 0;
}
.select-container {
padding: 0;
margin: 0;
width: 100%;
border-radius: 3px;
position: relative;
font-size: 15px;
background-color: #fff;
color: #000;
&:after {
top: 50%;
right: calc(0% + 15px);
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
border: 5px solid rgba(0, 0, 0, 0);
border-top-color: #000;
margin-top: -2px;
z-index: 100;
}
select {
padding: 0 35px 0 12px;
position: relative;
border: 1px solid #C6C6C6;
background: transparent none;
appearance: none;
cursor: pointer;
height: $input-height;
width: 100%;
text-overflow: ellipsis;
&:focus {
outline: none;
}
&:-moz-focusring {
color: transparent;
text-shadow: 0 0 0 #000;
}
option {
color: #000;
}
}
}
.results {
.root-result {
display: flex;
margin: 30px 0;
}
.information {
width: 100%;
}
.title-block {
> *{
display: inline-block;
}
.handle {
font-size: $small-font-size;
color: $grey;
}
}
.title {
font-size: 24px;
font-weight: bold;
margin: 0 5px 0 0;
}
.additional-information {
margin: 5px 0 20px 0;
font-size: $small-font-size;
color: $grey;
}
.description {
margin: 10px 0 20px 0;
}
.metadatas {
> div {
min-height: 27px;
display: flex;
align-items: center;
font-size: $small-font-size;
}
label {
color: $grey;
min-width: 100px;
}
}
.button {
text-align: right;
}
.button-link {
display: inline-block;
margin: 20px 0;
}
@media screen and (max-width: $small-view) {
.root-result {
flex-direction: column;
}
.title {
font-size: 20px;
}
.avatar {
margin: 0 auto 20px auto !important;
}
.thumbnail {
margin: 0 0 20px -10px !important;
}
.metadatas {
> div {
flex-direction: column;
align-items: flex-start;
margin: 10px 0;
}
label {
margin-bottom: 5px;
font-weight: bold;
}
}
}
}
.ti-tag {
background: $orange-darken !important;
}

View File

@ -0,0 +1,29 @@
import axios from 'axios'
import { buildApiUrl } from './utils'
import { ServerConfig } from '../../../shared'
const basePath = '/api/v1/config'
let serverConfig: ServerConfig
function getConfigHttp () {
return axios.get<ServerConfig>(buildApiUrl(basePath))
}
async function loadServerConfig () {
const res = await getConfigHttp()
serverConfig = res.data
return serverConfig
}
function getConfig () {
if (serverConfig) return Promise.resolve(serverConfig)
return loadServerConfig()
}
export {
getConfig,
loadServerConfig
}

View File

@ -0,0 +1,37 @@
import axios from 'axios'
import { ResultList } from '../../../PeerTube/shared/models/result-list.model'
import { VideoChannelsSearchQuery } from '../../../PeerTube/shared/models/search/video-channels-search-query.model'
import { VideosSearchQuery } from '../../../PeerTube/shared/models/search/videos-search-query.model'
import { EnhancedVideoChannel } from '../../../server/types/channel.model'
import { EnhancedVideo } from '../../../server/types/video.model'
import { buildApiUrl } from './utils'
const baseVideosPath = '/api/v1/search/videos'
const baseVideoChannelsPath = '/api/v1/search/video-channels'
function searchVideos (params: VideosSearchQuery) {
const options = {
params
}
if (params.search) Object.assign(options.params, { search: params.search })
return axios.get<ResultList<EnhancedVideo>>(buildApiUrl(baseVideosPath), options)
.then(res => res.data)
}
function searchVideoChannels (params: VideoChannelsSearchQuery) {
const options = {
params
}
if (params.search) Object.assign(options.params, { search: params.search })
return axios.get<ResultList<EnhancedVideoChannel>>(buildApiUrl(baseVideoChannelsPath), options)
.then(res => res.data)
}
export {
searchVideos,
searchVideoChannels
}

106
client/src/shared/utils.ts Normal file
View File

@ -0,0 +1,106 @@
import Vue from 'vue'
function buildApiUrl (path: string) {
const normalizedPath = path.startsWith('/') ? path : '/' + path
if (Vue.config.productionTip) return normalizedPath
return process.env.VUE_APP_API_URL + normalizedPath
}
function durationToString (duration: number) {
const hours = Math.floor(duration / 3600)
const minutes = Math.floor((duration % 3600) / 60)
const seconds = duration % 60
const minutesPadding = minutes >= 10 ? '' : '0'
const secondsPadding = seconds >= 10 ? '' : '0'
const displayedHours = hours > 0 ? hours.toString() + ':' : ''
return (
displayedHours + minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString()
).replace(/^0/, '')
}
function pageToAPIParams (page: number, itemsPerPage: number) {
const start = (page - 1) * itemsPerPage
const count = itemsPerPage
return { start, count }
}
function durationRangeToAPIParams (durationRange: string) {
if (!durationRange) {
return { durationMin: undefined, durationMax: undefined }
}
const fourMinutes = 60 * 4
const tenMinutes = 60 * 10
switch (durationRange) {
case 'short':
return { durationMin: undefined, durationMax: fourMinutes}
case 'medium':
return { durationMin: fourMinutes, durationMax: tenMinutes }
case 'long':
return { durationMin: tenMinutes, durationMax: undefined }
default:
console.error('Unknown duration range %s', durationRange)
return { durationMin: undefined, durationMax: undefined }
}
}
function publishedDateRangeToAPIParams (publishedDateRange: string) {
if (!publishedDateRange) {
return { startDate: undefined, endDate: undefined }
}
// today
const date = new Date()
date.setHours(0, 0, 0, 0)
switch (publishedDateRange) {
case 'today':
break
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
default:
console.error('Unknown published date range %s', publishedDateRange)
return { startDate: undefined, endDate: undefined }
}
return { startDate: date.toISOString(), endDate: undefined }
}
function extractTagsFromQuery (value: any | any[]) {
if (!value) return []
if (Array.isArray(value) === true) {
return (value as any[]).map(v => ({ text: v }))
}
return [ { text: value } ]
}
export {
buildApiUrl,
durationToString,
publishedDateRangeToAPIParams,
pageToAPIParams,
durationRangeToAPIParams,
extractTagsFromQuery
}

4
client/src/shims.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '*.vue' {
import Vue from 'vue'
export default Vue
}

817
client/src/views/Search.vue Normal file
View File

@ -0,0 +1,817 @@
<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>

33
client/tsconfig.json Normal file
View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "es5",
"module": "es2020",
"strict": false,
"jsx": "preserve",
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"baseUrl": ".",
"lib": [
"dom",
"es2015",
"es2016",
"es2017"
],
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src/**/*.ts",
"src/**/*.vue",
"tests/**/*.ts"
],
"exclude": [
"node_modules"
]
}

3
client/vue.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
publicPath: '/'
}

7624
client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,9 @@ elastic_search:
log:
level: 'debug' # debug/info/warning/error
search-instance:
name: 'PeerTube Search Index'
instances-index:
# Contains PeerTube instance hosts the indexer will index
# Must answer the following format: https://framagit.org/framasoft/peertube/instances-peertube#peertube-auto-follow-global-search
@ -28,6 +31,8 @@ instances-index:
hosts: null
api:
# Blacklist hosts that will not be returned by the search API
blacklist:
enabled: false
# Array of hosts
hosts: null

View File

@ -14,6 +14,7 @@ import { API_VERSION, CONFIG } from './server/initializers/constants'
import { VideosIndexer } from './server/lib/schedulers/videos-indexer'
import { initVideosIndex } from './server/lib/elastic-search-videos'
import { initChannelsIndex } from './server/lib/elastic-search-channels'
import { join } from 'path'
const app = express()
@ -34,6 +35,15 @@ app.use(cors())
const apiRoute = '/api/' + API_VERSION
app.use(apiRoute, apiRouter)
// Static client files
app.use('/js/', express.static(join(__dirname, '../client/dist/js')))
app.use('/css/', express.static(join(__dirname, '../client/dist/css')))
app.use('/img/', express.static(join(__dirname, '../client/dist/img')))
app.use('/*', function (req, res) {
return res.sendFile(join(__dirname, '../client/dist/index.html'))
})
// ----------- Errors -----------
// Catch 404 and forward to error handler

View File

@ -0,0 +1,23 @@
import * as express from 'express'
import { VideosIndexer } from '../../lib/schedulers/videos-indexer'
import { ServerConfig } from '../../../shared'
import { CONFIG } from '../../initializers/constants'
const configRouter = express.Router()
configRouter.get('/config',
getConfig
)
// ---------------------------------------------------------------------------
export { configRouter }
// ---------------------------------------------------------------------------
async function getConfig (req: express.Request, res: express.Response) {
return res.json({
searchInstanceName: CONFIG.SEARCH_INSTANCE.NAME,
indexedHostsCount: VideosIndexer.Instance.getIndexedHosts().length
} as ServerConfig)
}

View File

@ -1,10 +1,12 @@
import * as express from 'express'
import { badRequest } from '../../helpers/utils'
import { searchVideosRouter } from './search-videos'
import { configRouter } from './config'
import { searchChannelsRouter } from './search-channels'
import { searchVideosRouter } from './search-videos'
const apiRouter = express.Router()
apiRouter.use('/', configRouter)
apiRouter.use('/', searchVideosRouter)
apiRouter.use('/', searchChannelsRouter)
apiRouter.use('/ping', pong)

View File

@ -108,7 +108,7 @@ async function indexDocuments <T extends IndexableDoc> (options: {
function extractQueryResult (result: ApiResponse<any, any>) {
const hits = result.body.hits
return { total: hits.total.value, data: hits.hits.map(h => h._source) }
return { total: hits.total.value, data: hits.hits.map(h => Object.assign(h._source, { score: h._score })) }
}
export {

View File

@ -23,6 +23,9 @@ const CONFIG = {
LOG: {
LEVEL: config.get<string>('log.level')
},
SEARCH_INSTANCE: {
NAME: config.get<string>('search-instance.name')
},
INSTANCES_INDEX: {
URL: config.get<string>('instances-index.url'),
WHITELIST: {

View File

@ -2,7 +2,7 @@ import { CONFIG } from '../initializers/constants'
import { VideoChannel } from '@shared/models'
import { buildIndex, buildSort, elasticSearch, extractQueryResult, indexDocuments } from '../helpers/elastic-search'
import { logger } from '../helpers/logger'
import { DBChannel, IndexableChannel } from '../types/channel.model'
import { DBChannel, IndexableChannel, EnhancedVideoChannel } from '../types/channel.model'
import { ChannelsSearchQuery } from '../types/channel-search.model'
import { buildAvatarMapping, formatAvatarForAPI, formatAvatarForDB } from './elastic-search-avatar'
import { difference } from 'lodash'
@ -194,10 +194,12 @@ function formatChannelForDB (c: IndexableChannel): DBChannel {
}
}
function formatChannelForAPI (c: DBChannel, fromHost?: string): VideoChannel {
function formatChannelForAPI (c: DBChannel, fromHost?: string): EnhancedVideoChannel {
return {
id: c.id,
score: c.score,
url: c.url,
name: c.name,
host: c.host,

View File

@ -1,12 +1,11 @@
import { difference } from 'lodash'
import { buildUrl } from '../helpers/utils'
import { buildIndex, buildSort, elasticSearch, extractQueryResult, indexDocuments } from '../helpers/elastic-search'
import { logger } from '../helpers/logger'
import { buildUrl } from '../helpers/utils'
import { CONFIG } from '../initializers/constants'
import { VideosSearchQuery } from '../types/video-search.model'
import { DBVideo, DBVideoDetails, IndexableVideo, IndexableVideoDetails } from '../types/video.model'
import { DBVideo, DBVideoDetails, EnhancedVideo, IndexableVideo, IndexableVideoDetails } from '../types/video.model'
import { buildAvatarMapping, formatAvatarForAPI, formatAvatarForDB } from './elastic-search-avatar'
import { Video } from '../../PeerTube/shared/models'
function initVideosIndex () {
return buildIndex(CONFIG.ELASTIC_SEARCH.INDEXES.VIDEOS, buildVideosMapping())
@ -356,11 +355,13 @@ function formatVideoForDB (v: IndexableVideo | IndexableVideoDetails): DBVideo |
}
}
function formatVideoForAPI (v: DBVideo, fromHost?: string): Video {
function formatVideoForAPI (v: DBVideoDetails, fromHost?: string): EnhancedVideo {
return {
id: v.id,
uuid: v.uuid,
score: v.score,
createdAt: new Date(v.createdAt),
updatedAt: new Date(v.updatedAt),
publishedAt: new Date(v.publishedAt),
@ -387,6 +388,8 @@ function formatVideoForAPI (v: DBVideo, fromHost?: string): Video {
description: v.description,
duration: v.duration,
tags: v.tags,
thumbnailPath: v.thumbnailPath,
thumbnailUrl: buildUrl(v.host, v.thumbnailPath),

View File

@ -15,10 +15,12 @@ type GetChannelQueueParam = { host: string, name: string }
export class VideosIndexer extends AbstractScheduler {
private static instance: AbstractScheduler
private static instance: VideosIndexer
protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.videosIndexer
private indexedHosts: string[] = []
private readonly indexVideoQueue: AsyncQueue<GetVideoQueueParam>
private readonly indexChannelQueue: AsyncQueue<GetChannelQueueParam>
@ -57,6 +59,10 @@ export class VideosIndexer extends AbstractScheduler {
this.indexChannelQueue.push({ name, host })
}
getIndexedHosts () {
return this.indexedHosts
}
protected async internalExecute () {
return this.runVideosIndexer()
}
@ -65,6 +71,8 @@ export class VideosIndexer extends AbstractScheduler {
logger.info('Running videos indexer.')
const { indexHosts, removedHosts } = await buildInstanceHosts()
this.indexedHosts = indexHosts
await removeVideosFromHosts(removedHosts)
await Bluebird.map(indexHosts, async host => {

View File

@ -1,6 +1,5 @@
import { IndexableDoc } from './elastic-search.model'
import { VideoChannel, VideoChannelSummary, Avatar } from '@shared/models'
import { Account } from '@shared/models/actors/account.model'
import { VideoChannel, VideoChannelSummary, Avatar, Account } from '../../PeerTube/shared/models'
export interface IndexableChannel extends VideoChannel, IndexableDoc {
url: string
@ -14,8 +13,15 @@ export interface DBChannel extends Omit<VideoChannel, 'isLocal'> {
ownerAccount?: Account & { handle: string, avatar: Avatar & { url: string } }
avatar?: Avatar & { url: string }
score?: number
}
export interface DBChannelSummary extends VideoChannelSummary {
indexedAt: Date
}
// Results from the search API
export interface EnhancedVideoChannel extends VideoChannel {
score: number
}

View File

@ -1,8 +1,6 @@
import { VideoChannel, VideoChannelSummary } from '@shared/models/videos/channel/video-channel.model'
import { Account, AccountSummary } from '@shared/models/actors/account.model'
import { Video, VideoDetails } from '@shared/models/videos/video.model'
import { Account, AccountSummary, Avatar, Video, VideoChannel, VideoChannelSummary, VideoDetails } from '../../PeerTube/shared/models'
import { IndexableDoc } from './elastic-search.model'
import { Avatar } from '@shared/models'
type ActorExtended = {
handle: string
@ -21,6 +19,8 @@ export interface DBVideoDetails extends Omit<VideoDetails, 'isLocal'> {
account: Account & ActorExtended
channel: VideoChannel & ActorExtended
score?: number
}
export interface DBVideo extends Omit<Video, 'isLocal'> {
@ -31,3 +31,10 @@ export interface DBVideo extends Omit<Video, 'isLocal'> {
account: AccountSummary & ActorExtended
channel: VideoChannelSummary & ActorExtended
}
// Results from the search API
export interface EnhancedVideo extends Video {
tags: VideoDetails['tags']
score: number
}

1
shared/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './server-config.model'

View File

@ -0,0 +1,5 @@
export interface ServerConfig {
searchInstanceName: string
indexedHostsCount: number
}