Add client
This commit is contained in:
parent
83cec31a6c
commit
c6d844d4f7
2
PeerTube
2
PeerTube
|
@ -1 +1 @@
|
|||
Subproject commit 3521ab8fc01da85fa804439ca6e297e6fb364c58
|
||||
Subproject commit b2c76204f9d2900b9fd58e6618e343059821d601
|
|
@ -0,0 +1,2 @@
|
|||
VUE_APP_TITLE=PeerTube Global Search
|
||||
VUE_APP_FOOTER=Made with ❤️ by Framasoft
|
|
@ -0,0 +1,2 @@
|
|||
VUE_APP_API_URL=http://localhost:3234
|
||||
#VUE_APP_API_URL=https://search.joinpeertube.org
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"plugins": {
|
||||
"autoprefixer": {}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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 |
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from './search-url.model'
|
|
@ -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[]
|
||||
}
|
|
@ -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
|
||||
}
|
||||
]
|
||||
})
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
declare module '*.vue' {
|
||||
import Vue from 'vue'
|
||||
export default Vue
|
||||
}
|
|
@ -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>
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
publicPath: '/'
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||
|
|
10
server.ts
10
server.ts
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export * from './server-config.model'
|
|
@ -0,0 +1,5 @@
|
|||
export interface ServerConfig {
|
||||
searchInstanceName: string
|
||||
|
||||
indexedHostsCount: number
|
||||
}
|
Loading…
Reference in New Issue