Refactor and redesign client

This commit is contained in:
Chocobozzz 2022-12-16 14:44:54 +01:00
parent 7f12b64bd6
commit 8ed5c72945
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
45 changed files with 3029 additions and 3183 deletions

View File

@ -42,7 +42,9 @@
2,
{
"SwitchCase": 1,
"MemberExpression": "off"
"MemberExpression": "off",
// https://github.com/eslint/eslint/issues/15299
"ignoredNodes": ["PropertyDefinition"]
}
],
"@typescript-eslint/consistent-type-assertions": [
@ -85,6 +87,8 @@
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-extraneous-class": "off",
"@typescript-eslint/prefer-nullish-coalescing": "off",
"@typescript-eslint/consistent-indexed-object-style": "off",
// bugged but useful
"@typescript-eslint/restrict-plus-operands": "off"
},

View File

@ -12,7 +12,11 @@
],
"rules": {
"vue/multi-word-component-names": "off",
"vue/max-attributes-per-line": "off",
"vue/html-self-closing": "off",
"vue/singleline-html-element-content-newline": "off",
"vue/require-default-prop": "off",
"@typescript-eslint/no-explicit-any": "off"
"vue/html-indent": "off",
"vue/multiline-html-element-content-newline": "off"
}
}

1
client/.gitignore vendored
View File

@ -1,2 +1,3 @@
src/locale/**/*~
dist/
stats.html

View File

@ -12,25 +12,29 @@
},
"dependencies": {},
"devDependencies": {
"@babel/types": "^7.20.5",
"@popperjs/core": "^2.11.6",
"@sipec/vue3-tags-input": "^3.0.4",
"@types/axios": "^0.14.0",
"@types/markdown-it": "^12.2.3",
"@typescript-eslint/eslint-plugin": "^5.12.1",
"@typescript-eslint/parser": "^5.12.1",
"@vitejs/plugin-vue": "^2.2.2",
"@vue/eslint-config-typescript": "^10.0.0",
"axios": "^0.26.0",
"@vitejs/plugin-vue": "^4.0.0",
"@vue/eslint-config-typescript": "^11.0.2",
"axios": "^1.2.1",
"bootstrap": "^5.2.3",
"eslint": "^8.9.0",
"eslint-plugin-vue": "^8.5.0",
"markdown-it": "^12.3.2",
"eslint-plugin-vue": "^9.8.0",
"markdown-it": "^13.0.1",
"nprogress": "^0.2.0",
"rollup-plugin-visualizer": "^5.8.3",
"sass": "^1.49.8",
"typescript": "~4.5.5",
"vite": "^2.8.4",
"typescript": "~4.9.4",
"vite": "^4.0.1",
"vue": "^3.2.31",
"vue-matomo": "^4.1.0",
"vue-router": "^4.0.12",
"vue-tsc": "^0.31.4",
"vue3-gettext": "2.2.0-alpha.1"
"vue-tsc": "^1.0.13",
"vue3-gettext": "2.3.4"
}
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,42 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" viewBox="2799 -911 16 22" version="1.1" id="svg13" sodipodi:docname="logo.svg" width="16" height="22" inkscape:version="0.92.2 5c3e80d, 2017-08-06">
<metadata id="metadata17">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview pagecolor="#ffffff" bordercolor="#666666" borderopacity="1" objecttolerance="10" gridtolerance="10" guidetolerance="10" inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:window-width="1916" inkscape:window-height="1040" id="namedview15" showgrid="false" inkscape:zoom="29.790476" inkscape:cx="-1.1827326" inkscape:cy="12.088" inkscape:window-x="0" inkscape:window-y="18" inkscape:window-maximized="0" inkscape:current-layer="svg13"/>
<defs id="defs4">
<style id="style2">
.cls-3 {
fill: #211f20;
}
.cls-4 {
fill: #737373;
}
.cls-5 {
fill: #f1680d;
}
.cls-6 {
fill: rgba(255, 255, 255, 0);
}
</style>
</defs>
<g id="Artboard_1" data-name="Artboard 1" class="cls-1" transform="translate(0.03356777,-1.9929667)">
<g id="Symbol_3_1" data-name="Symbol 3 1" transform="translate(2759,-975)">
<g id="Group_44" data-name="Group 44" transform="translate(0,2.333)">
<path id="Path_4" data-name="Path 4" class="cls-3" d="m -949,-500 v 10.667 l 8,-5.333" transform="translate(989,564)" inkscape:connector-curvature="0" style="fill:#211f20"/>
<path id="Path_5" data-name="Path 5" class="cls-4" d="m -949,-500 v 10.667 l 8,-5.333" transform="translate(989,574.667)" inkscape:connector-curvature="0" style="fill:#737373"/>
<path id="Path_6" data-name="Path 6" class="cls-5" d="m -949,-500 v 10.667 l 8,-5.333" transform="translate(997,569.333)" inkscape:connector-curvature="0" style="fill:#f1680d"/>
<path id="Path_7" data-name="Path 7" class="cls-6" d="M 0,0 V 10.667 L 8,5.333 Z" transform="rotate(180,24,40)" inkscape:connector-curvature="0" style="fill:rgba(255,255,255,0)"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -1,34 +1,20 @@
<template>
<span class="actor">
<img v-if="avatarUrl && !avatarError" :src="avatarUrl" alt="" :class="{ account: isAccount }" @error="setAvatarError()" />
<a
:href="actor.url"
rel="nofollow noreferrer noopener"
target="_blank"
class="actor"
:title="linkTitle"
>
<img
v-if="avatarUrl && !avatarError"
:src="avatarUrl"
alt=""
:class="{ account: isAccount }"
@error="setAvatarError()"
>
<strong>{{ actor.displayName }}</strong>
<span class="actor-handle">
{{ actor.name }}@{{ actor.host }}
:href="actor.url" :title="linkTitle"
class="peertube-link wrap-text" rel="nofollow noreferrer noopener" target="_blank"
>{{ actor.displayName }}</a>
</span>
</a>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
import { AccountSummary, VideoChannelSummary } from '../../../PeerTube/shared/models'
import { defineComponent } from 'vue'
export default defineComponent({
props: {
actor: Object as PropType<AccountSummary | VideoChannelSummary>,
actor: Object,
type: String
},
@ -39,7 +25,7 @@
},
computed: {
avatarUrl (): string {
avatarUrl () {
const avatars = this.actor.avatars
if (avatars.length === 0) return ''
@ -53,13 +39,13 @@
return avatars[0].url
},
linkTitle (): string {
linkTitle () {
if (this.type === 'channel') return this.$gettext('Go on this channel page')
return this.$gettext('Go on this account page')
},
isAccount (): boolean {
isAccount () {
return this.type === 'account'
}
},
@ -72,49 +58,24 @@
})
</script>
<style lang="scss">
<style lang="scss" scoped>
@import '../scss/_variables';
.actor {
font-size: inherit;
color: #000;
text-decoration: none;
display: flex;
flex-wrap: wrap;
&:hover {
text-decoration: underline;
}
$size: 20px;
img {
object-fit: cover;
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
width: $size;
height: $size;
min-width: $size;
min-height: $size;
margin-right: 5px;
&.account {
border-radius: 50%;
}
}
.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>

View File

@ -1,55 +1,42 @@
<template>
<div class="channel root-result">
<a
target="_blank"
rel="nofollow noreferrer noopener"
:href="channel.url"
:title="discoverChannelMessage"
class="avatar"
>
<img
v-if="channel.avatar"
:src="channel.avatar.url"
alt=""
>
<img
v-else
src="/img/default-avatar.png"
alt=""
>
<div class="channel search-card-result">
<a class="image-container" target="_blank" rel="nofollow noreferrer noopener" :href="channel.url" :title="discoverChannelMessage">
<img v-if="hasAvatar()" :src="getAvatarUrl()" alt="" />
<img v-else src="/img/default-avatar.png" alt="" />
</a>
<div class="information">
<div class="title-block">
<h5 class="title">
<a
target="_blank"
rel="nofollow noreferrer noopener"
:href="channel.url"
:title="discoverChannelMessage"
>
<div class="flex-grow-1">
<h4>
<strong>
<a target="_blank" rel="nofollow noreferrer noopener" class="wrap-text" :href="channel.url" :title="discoverChannelMessage">
{{ channel.displayName }}
</a>
</h5>
</strong>
</h4>
<span class="handle">{{ channel.name }}@{{ channel.host }}</span>
</div>
<div class="small-separator"></div>
<div class="additional-information">
<div class="followers-count">
{{ followersCountMessage }}
<!-- eslint-disable vue/no-v-html -->
<div
class="description wrap-text mb-3"
v-html="descriptionHTML"
></div>
<!-- eslint-enable -->
<div class="metadata">
<div>
<label>{{ $gettext('Channel created on platform') }}</label>
<a class="peertube-link" target="_blank" rel="nofollow noreferrer noopener" :href="platformUrl">{{ host }}</a>
</div>
</div>
<div class="description">
{{ channel.description }}
</div>
<div class="button">
<div class="button-link-container">
<a
class="button-link"
rel="nofollow noreferrer noopener"
class="peertube-button peertube-primary-button peertube-button-link"
target="_blank"
rel="nofollow noreferrer noopener"
:href="channel.url"
>
{{ discoverChannelMessage }}
@ -60,51 +47,89 @@
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
import { VideoChannel } from '../../../PeerTube/shared/models'
import { defineComponent } from 'vue'
export default defineComponent({
props: {
channel: Object as PropType<VideoChannel>
channel: Object
},
data () {
return {
descriptionHTML: ''
}
},
computed: {
host (): string {
host () {
const url = this.channel.url
return new URL(url as string).host
return new URL(url).host
},
discoverChannelMessage (): string {
return this.$gettextInterpolate(this.$gettext('Discover this channel on %{host}'), { host: this.host })
platformUrl () {
const url = this.channel.url
const parsed = new URL(url)
return parsed.protocol + '//' + parsed.host
},
followersCountMessage (): string {
const translated = this.$ngettext('%{ n } follower', '%{ n } followers', this.channel.followersCount)
discoverChannelMessage () {
return this.$gettext('Discover this channel on %{host}', { host: this.host })
}
},
return this.$gettextInterpolate(translated, { n: this.channel.followersCount })
mounted () {
this.lazyRenderMarkdown(this.channel.description)
.then(html => this.descriptionHTML = html)
},
methods: {
hasAvatar () {
if (this.channel.avatar) return true
if (!Array.isArray(this.channel.avatars)) return false
return this.channel.avatars.length !== 0
},
getAvatarUrl () {
if (!Array.isArray(this.channel.avatars)) {
if (!this.channel.avatar) return ''
return this.channel.avatar.url
}
if (this.channel.avatar.length === 0) return ''
const biggestAvatar = [ ...this.channel.avatars ].sort((a1, a2) => {
if (a1.width < a2.width) return 1
if (a1.width === a2.width) return 0
return -1
})[0]
return biggestAvatar.url
}
}
})
</script>
<style lang="scss">
<style lang="scss" scoped>
@import '../scss/_variables';
.channel {
.avatar {
width: $thumbnail-width;
min-width: $thumbnail-width;
margin-right: 20px;
display: flex;
justify-content: center;
$size: 140px;
.image-container {
img {
object-fit: cover;
width: 110px;
height: 110px;
min-width: 110px;
min-height: 110px;
max-width: $size;
max-height: $size;
min-width: $size;
min-height: $size;
box-shadow: 4px 4px 0 0 $beige-700;
border-radius: 5px;
}
}
}

View File

@ -0,0 +1,458 @@
<template>
<div class="filters-content">
<div class="form-group small-height">
<div class="radio-label label-container">
<label>{{ $gettext('Display sensitive content') }}</label>
<button v-if="formNSFW !== undefined" class="reset-button" @click="resetField('nsfw')">
{{ $gettext('Reset') }}
</button>
</div>
<div class="peertube-radio-container">
<input id="sensitiveContentYes" v-model="formNSFW" type="radio" name="sensitiveContent" value="both">
<label for="sensitiveContentYes" class="radio">{{ $gettext('Yes') }}</label>
</div>
<div class="peertube-radio-container">
<input id="sensitiveContentNo" v-model="formNSFW" type="radio" name="sensitiveContent" value="false">
<label for="sensitiveContentNo" class="radio">{{ $gettext('No') }}</label>
</div>
</div>
<div class="form-group small-height">
<div class="radio-label label-container">
<label>{{ $gettext('Display only') }}</label>
<button v-if="formIsLive !== undefined" class="reset-button" @click="resetField('isLive')">{{ $gettext('Reset') }}</button>
</div>
<div class="peertube-radio-container">
<input id="isLiveYes" v-model="formIsLive" type="radio" name="isLive" value="both">
<label for="isLiveYes" class="radio">{{ $gettext('Live videos') }}</label>
</div>
<div class="peertube-radio-container">
<input id="isLiveNo" v-model="formIsLive" type="radio" name="isLive" value="false">
<label for="isLiveNo" class="radio">{{ $gettext('VOD videos') }}</label>
</div>
</div>
<div class="form-group">
<div class="radio-label label-container">
<label>{{ $gettext('Published date') }}</label>
<button v-if="formPublishedDateRange !== undefined" class="reset-button" @click="resetField('publishedDateRange')">
{{ $gettext('Reset') }}
</button>
</div>
<div v-for="date in publishedDateRanges" :key="date.id" class="peertube-radio-container">
<input :id="date.id" v-model="formPublishedDateRange" type="radio" name="publishedDateRange" :value="date.id">
<label :for="date.id" class="radio">{{ date.label }}</label>
</div>
</div>
<div class="form-group">
<div class="radio-label label-container">
<label>{{ $gettext('Duration') }}</label>
<button v-if="formDurationRange !== undefined" class="reset-button" @click="resetField('durationRange')">
{{ $gettext('Reset') }}
</button>
</div>
<div v-for="duration in durationRanges" :key="duration.id" class="peertube-radio-container">
<input :id="duration.id" v-model="formDurationRange" type="radio" name="durationRange" :value="duration.id">
<label :for="duration.id" class="radio">{{ duration.label }}</label>
</div>
</div>
<div class="form-group">
<label for="category">{{ $gettext('Category') }}</label>
<button v-if="formCategoryOneOf !== undefined" class="reset-button" @click="resetField('categoryOneOf')">
{{ $gettext('Reset') }}
</button>
<div class="select-container">
<select id="category" v-model="formCategoryOneOf" name="category">
<option :value="undefined">
{{ $gettext('Display all categories') }}
</option>
<option v-for="category in videoCategories" :key="category.id" :value="category.id">
{{ category.label }}
</option>
</select>
</div>
</div>
<div class="form-group">
<label for="licence">{{ $gettext('Licence') }}</label>
<button v-if="formLicenceOneOf !== undefined" class="reset-button" @click="resetField('licenceOneOf')">
{{ $gettext('Reset') }}
</button>
<div class="select-container">
<select id="licence" v-model="formLicenceOneOf" name="licence">
<option :value="undefined">
{{ $gettext('Display all licenses') }}
</option>
<option v-for="licence in videoLicences" :key="licence.id" :value="licence.id">
{{ licence.label }}
</option>
</select>
</div>
</div>
<div class="form-group">
<label for="language">{{ $gettext('Language') }}</label>
<button v-if="formLanguageOneOf !== undefined" class="reset-button" @click="resetField('languageOneOf')">
{{ $gettext('Reset') }}
</button>
<div class="select-container">
<select id="language" v-model="formLanguageOneOf" name="language">
<option :value="undefined">
{{ $gettext('Display all languages') }}
</option>
<option v-for="language in videoLanguages" :key="language.id" :value="language.id">
{{ language.label }}
</option>
</select>
</div>
</div>
<div class="form-group">
<label for="host">{{ $gettext('PeerTube instance') }}</label>
<input id="host" v-model="formHost" type="text" name="host" class="classic-input-text" />
</div>
<div class="form-group">
<label for="tagsAllOf">{{ $gettext('All of these tags') }}</label>
<button v-if="formTagsAllOf.length !== 0" class="reset-button" @click="resetField('tagsAllOf')">
{{ $gettext('Reset') }}
</button>
<vue-tags-input
v-model="formTagAllOf" :placeholder="tagsPlaceholder" :tags="formTagsAllOf"
@tags-changed="newTags => formTagsAllOf = newTags"
/>
</div>
<div class="form-group">
<label for="tagsOneOf">{{ $gettext('One of these tags') }}</label>
<button v-if="formTagsOneOf.length !== 0" class="reset-button" @click="resetField('tagsOneOf')">
{{ $gettext('Reset') }}
</button>
<vue-tags-input
v-model="formTagOneOf" :placeholder="tagsPlaceholder" :tags="formTagsOneOf"
@tags-changed="newTags => formTagsOneOf = newTags"
/>
</div>
<div class="button-block mt-3">
<router-link class="peertube-button peertube-button-link peertube-primary-button" :to="{ path: '/search', query: { ...getUrlQuery() } }">
{{ applyFiltersLabel }}
</router-link>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import VueTagsInput from '@sipec/vue3-tags-input'
import { SearchUrl } from '@/models/search-url.model'
import { extractTagsFromQuery } from '@/shared/utils'
export default defineComponent({
components: {
'vue-tags-input': VueTagsInput,
},
data () {
return {
formNSFW: undefined,
formHost: '',
formPublishedDateRange: undefined,
formDurationRange: undefined,
formCategoryOneOf: undefined,
formLicenceOneOf: undefined,
formLanguageOneOf: undefined,
formTagAllOf: '',
formTagOneOf: '',
formTagsAllOf: [],
formTagsOneOf: [],
formIsLive: undefined,
activeFilters: 0
}
},
computed: {
applyFiltersLabel (): string {
return this.$gettext('Apply filters')
},
inputPlaceholder (): string {
return this.$gettext('Keyword, channel, video, playlist, etc.')
},
tagsPlaceholder (): string {
return this.$gettext('Add tag')
},
publishedDateRanges (): { id: string, label: string }[] {
return [
{
id: 'any_published_date',
label: this.$gettext('Any')
},
{
id: 'today',
label: this.$gettext('Today')
},
{
id: 'last_7days',
label: this.$gettext('Last 7 days')
},
{
id: 'last_30days',
label: this.$gettext('Last 30 days')
},
{
id: 'last_365days',
label: this.$gettext('Last 365 days')
}
]
},
durationRanges (): { id: string, label: string }[] {
return [
{
id: 'any_duration',
label: this.$gettext('Any')
},
{
id: 'short',
label: this.$gettext('Short (< 4 min)')
},
{
id: 'medium',
label: this.$gettext('Medium (4-10 min)')
},
{
id: 'long',
label: this.$gettext('Long (> 10 min)')
}
]
},
videoCategories (): { id: string, label: string }[] {
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 (): { id: string, label: string }[] {
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 (): { id: string, label: string }[] {
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('русский') }
]
}
},
mounted () {
this.loadUrl()
},
methods: {
getUrlQuery (): SearchUrl {
return {
...this.$route.query,
nsfw: this.formNSFW,
host: this.formHost || undefined,
isLive: this.formIsLive,
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)
}
},
loadUrl () {
const query = this.$route.query as SearchUrl
if (query.nsfw) this.formNSFW = query.nsfw
else this.formNSFW = undefined
if (query.publishedDateRange) this.formPublishedDateRange = query.publishedDateRange
else this.formPublishedDateRange = undefined
if (query.durationRange) this.formDurationRange = query.durationRange
else this.formDurationRange = undefined
if (query.categoryOneOf) this.formCategoryOneOf = query.categoryOneOf
else this.formCategoryOneOf = undefined
if (query.licenceOneOf) this.formLicenceOneOf = query.licenceOneOf
else this.formLicenceOneOf = undefined
if (query.languageOneOf) this.formLanguageOneOf = query.languageOneOf
else this.formLanguageOneOf = undefined
if (query.tagsAllOf) this.formTagsAllOf = extractTagsFromQuery(query.tagsAllOf)
else this.formTagsAllOf = []
if (query.tagsOneOf) this.formTagsOneOf = extractTagsFromQuery(query.tagsOneOf)
else this.formTagsOneOf = []
if (query.isLive) this.formIsLive = query.isLive
else this.formIsLive = undefined
if (query.host) this.formHost = query.host
else this.formHost = ''
},
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 === 'isLive') this.formIsLive = undefined
else if (field === 'tagsAllOf') this.formTagsAllOf = []
else if (field === 'tagsOneOf') this.formTagsOneOf = []
else if (field === 'host') this.formHost = ''
}
}
})
</script>
<style scoped lang="scss">
@use 'sass:math';
@import '../scss/_variables';
.filters-content {
display: flex;
flex-wrap: wrap;
margin-top: 1.25rem;
.form-group:nth-child(2n-1) {
padding-right: 20px;
}
.form-group {
width: 50%;
min-height: 60px;
display: inline-block;
margin: 10px 0;
font-size: 0.875rem;
}
@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;
}
.form-group.small-height {
min-height: 30px;
}
.peertube-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: 0.7rem;
opacity: 0.7;
margin-left: 5px;
cursor: pointer;
}
}
</style>

View File

@ -1,84 +1,19 @@
<template>
<footer id="main-footer">
<div class="header">
<img
src="/img/bottom-peertube-logo.svg"
alt=""
>
<div class="text-center">
<img src="/img/bottom-peertube-logo.svg" alt="PeerTube">
<div
v-translate
class="description"
>
A free software to take back control of your videos
<div class="mt-3">
{{ $gettext('A free software to take back control of your videos') }}
</div>
</div>
<div class="columns">
<div>
<div
v-translate
class="subtitle"
>
Open your own videos website with PeerTube!
</div>
<div class="d-flex mt-4 justify-content-center mb-5 fs-7">
<a class="peertube-link text-center mx-3" href="https://joinpeertube.org" target="_blank">{{ $gettext('Learn more about PeerTube') }}</a>
<a
v-translate
target="_blank"
href="https://docs.joinpeertube.org/#/install-any-os"
>Install PeerTube</a>
<a class="peertube-link text-center mx-3" href="https://framagit.org/framasoft/peertube/search-index/" target="_blank">{{ $gettext('Search Index source code') }}</a>
<a
v-translate
target="_blank"
href="https://joinpeertube.org#what-is-peertube"
>Why should I have my own PeerTube website?</a>
</div>
<div>
<div
v-translate
class="subtitle"
>
Create an account to take back control of your videos
</div>
<a
v-translate
target="_blank"
href="https://joinpeertube.org/instances"
>Open an account on a PeerTube website</a>
<a
v-translate
target="_blank"
href="https://docs.joinpeertube.org/#/use-library?id=playlist"
>Create playlists</a>
</div>
</div>
<div class="big-link">
<a
v-translate
href="https://joinpeertube.org"
target="_blank"
> &gt;&gt; Check all guides on joinpeertube.org &lt;&lt; </a>
</div>
<div class="footer">
<a
v-translate
href="https://framagit.org/framasoft/peertube/search-index/"
target="_blank"
>Source code</a>
<a
v-if="legalNoticesUrl"
v-translate
:href="legalNoticesUrl"
target="_blank"
>Legal notices</a>
<a v-if="legalNoticesUrl" :href="legalNoticesUrl" target="_blank">{{ $gettext('Legal notices') }}</a>
</div>
</footer>
</template>
@ -104,115 +39,18 @@
<style lang="scss">
@import '../scss/_variables';
@import '../scss/bootstrap-mixins';
footer {
width: $container-width;
margin: auto;
background-color: $orange-lighten;
padding: 30px 100px 10px 100px;
font-family: monospace;
.header {
margin: 50px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
> *:first-child {
@include margin(2rem);
img {
width: 100%;
max-width: 500px;
margin-bottom: 20px;
}
}
.description {
color: $brown;
text-align: center;
}
.columns {
display: flex;
> div {
display: flex;
flex-direction: column;
text-align: center;
&:first-child {
margin-right: 100px;
}
}
@media screen and (max-width: $container-width) {
flex-direction: column;
> div {
margin-top: 50px;
margin-right: 0 !important;
}
}
}
.columns a,
.big-link a {
color: $orange;
margin: 0 auto 20px auto;
min-height: 40px;
display: flex;
width: fit-content;
&:hover {
color: $orange-darken;
}
}
.subtitle {
color: $brown;
font-size: 20px;
margin-bottom: 50px;
}
.big-link {
margin-top: 50px;
a {
font-size: 20px;
font-weight: bold;
text-decoration: none;
text-align: center;
}
}
.footer {
margin-top: 80px;
display: flex;
justify-content: center;
a {
color: $orange;
font-size: 10px;
margin-right: 10px;
}
}
@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;
}
.footer a {
font-size: 16px;
}
}
}

View File

@ -4,39 +4,29 @@
<interface-language-dropdown class="interface-language-dropdown" />
<h1>
<router-link to="/" :title="$gettext('Come back to homepage')">
<span v-if="configLoaded && !titleImageUrl">{{ indexName }}</span>
<img
class="title-image"
:src="titleImageUrl"
:alt="indexName"
>
<img v-else class="title-image" :src="titleImageUrl" :alt="indexName" />
</router-link>
</h1>
<template v-if="!smallFormat">
<h4>
<div v-translate>
A search engine of <a
href="https://joinpeertube.org"
target="_blank"
>PeerTube</a> videos, channels and playlists
</div>
<SafeHTML>
{{
$gettext('A search engine of <a class="peertube-link" href="https://joinpeertube.org" target="_blank">PeerTube</a> videos, channels and playlists', {}, true)
}}
</SafeHTML>
<div v-translate>
Developed by <a
href="https://framasoft.org"
target="_blank"
>Framasoft</a>
</div>
<br />
<SafeHTML>
{{
$gettext('Developed by <a class="peertube-link" href="https://framasoft.org" target="_blank">Framasoft</a>', {}, true)
}}
</SafeHTML>
</h4>
<div class="search-home">
<img
v-if="searchImageUrl"
:src="searchImageUrl"
alt=""
>
</div>
</template>
</header>
</div>
@ -62,7 +52,6 @@
return {
configLoaded: false,
titleImageUrl: '',
searchImageUrl: ''
}
},
@ -73,10 +62,6 @@
? buildApiUrl(config.searchInstanceNameImage)
: ''
this.searchImageUrl = config.searchInstanceSearchImage
? buildApiUrl(config.searchInstanceSearchImage)
: buildApiUrl('/img/search-home.png')
this.configLoaded = true
}
})
@ -84,14 +69,15 @@
<style lang="scss">
@import '../scss/_variables';
@import '../scss/bootstrap-mixins.scss';
header {
@include margin-bottom(3rem);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 30px;
font-family: monospace;
.interface-language-dropdown {
width: 20px;
@ -100,69 +86,32 @@
top: 10px;
}
.search-home {
width: 300px;
height: 250px;
margin: 30px 0 0 0;
img {
width: inherit;
height: inherit;
}
}
h1 {
font-size: 50px;
@include font-size(3rem);
// Because it's loaded dynamically
min-height: 70px;
margin: 0;
text-align: center;
}
.title-image {
max-width: 500px;
width: 500px;
height: 78px;
}
h4 {
@include font-size(1.25rem);
font-weight: normal;
margin: 0;
text-align: center;
line-height: 25px;
line-height: inherit;
margin: 1rem 0 0;
a {
color: #000;
font-weight: $font-semibold;
@include media-breakpoint-down(sm) {
@include font-size(1rem);
}
}
@media screen and (max-width: $small-screen) {
h1 {
font-size: 30px;
margin-top: 20px;
margin-bottom: 10px;
min-height: unset;
}
h4 {
font-size: 15px !important;
line-height: initial;
div:first-child {
margin-bottom: 5px;
}
}
img {
width: 100%;
max-width: 300px;
}
}
}
@media screen and (max-height: 400px) {
.search-home {
display: none;
}
}
</style>

View File

@ -96,7 +96,7 @@
padding: .5rem 0;
position: absolute;
text-align: left;
font-size: 14px;
font-size: 0.875rem;
a {
text-decoration: none;

View File

@ -1,74 +1,62 @@
<template>
<div
v-if="searchDone"
class="pagination"
>
<router-link
class="previous"
:class="{ 'none-opacity': modelValue === 1 }"
:to="{ query: buildPageUrlQuery(modelValue - 1) }"
>
<div v-if="searched && hasPages()" class="pagination">
<router-link class="previous" :class="{ 'opacity-0': modelValue === 1 }" :to="{ query: { ...getUrlQuery(modelValue - 1) } }">
{{ $gettext('Previous page') }}
</router-link>
<div class="pages">
<template
v-for="page in pages"
:key="page"
>
<router-link
v-if="page !== modelValue"
class="go-to-page"
:to="{ query: buildPageUrlQuery(page) }"
>
<template v-for="page in pages" :key="page">
<router-link v-if="page !== modelValue" class="go-to-page" :to="{ query: { ...getUrlQuery(page) } }">
{{ page }}
</router-link>
<div
v-else
class="current"
>
<div v-else class="current">
{{ page }}
</div>
</template>
</div>
<router-link
class="next"
:class="{ 'none-opacity': modelValue >= maxPage }"
:to="{ query: buildPageUrlQuery(+modelValue + 1) }"
>
<router-link class="next" :class="{ 'opacity-0': modelValue >= maxPage }" :to="{ query: { ...getUrlQuery(+modelValue + 1) } }">
{{ $gettext('Next page') }}
</router-link>
</div>
</template>
<script lang="ts">
import { SearchUrl } from '@/models'
import { defineComponent } from 'vue'
export default defineComponent({
props: {
maxPage: Number,
searchDone: Boolean,
searched: Boolean,
modelValue: Number,
pages: Array as () => number[]
},
methods: {
buildPageUrlQuery (page: number) {
const query = this.$route.query
getUrlQuery (page: number): SearchUrl {
return {
...this.$route.query,
return Object.assign({}, query, { page })
page: page + ''
}
},
hasPages () {
return this.pages.length !== 0
}
}
})
</script>
<style lang="scss">
@import '../scss/_variables';
@import '../scss/bootstrap-mixins';
.pagination {
margin-top: 50px;
@include margin-top(3rem);
display: flex;
justify-content: center;
@ -81,7 +69,7 @@
}
a {
color: $orange;
color: $orange-main;
text-decoration: none;
&:hover {
@ -98,7 +86,7 @@
}
@media screen and (max-width: $small-view) {
font-size: 14px;
font-size: 0.875rem;
}
}
</style>

View File

@ -1,73 +1,68 @@
<template>
<div class="playlist root-result">
<div class="thumbnail">
<a
class="img"
:title="watchMessage"
target="_blank"
rel="nofollow noreferrer noopener"
:href="playlist.url"
>
<img
:src="playlist.thumbnailUrl"
alt=""
>
<div class="playlist search-card-result">
<div class="image-container">
<a class="img" :title="watchMessage" target="_blank" rel="nofollow noreferrer noopener" :href="playlist.url">
<img :src="playlist.thumbnailUrl" alt="" :class="{ error: thumbnailError }" @error="setThumbnailError()">
<span class="videos-length">{{ videosLengthLabel }}</span>
<div class="behind-1"></div>
<div class="behind-2"></div>
<div class="behind-3"></div>
<span class="videos-length">
<svg width="10" height="13" viewBox="0 0 10 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.47955e-05 6.26032V0L4.75 3.13016L2.47955e-05 6.26032Z" fill="#212529" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.47955e-05 12.5206V6.26025L4.75 9.39041L2.47955e-05 12.5206Z" fill="#212529" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.75002 9.39045V3.13013L9.5 6.26029L4.75002 9.39045Z" fill="#212529" />
</svg>
{{ videosLengthLabel }}
</span>
</a>
</div>
<div class="information">
<h5 class="title">
<a
:title="watchMessage"
target="_blank"
rel="nofollow noreferrer noopener"
:href="playlist.url"
>{{ playlist.displayName }}</a>
</h5>
<div class="flex-grow-1">
<h4>
<strong>
<a class="wrap-text" :title="watchMessage" target="_blank" rel="nofollow noreferrer noopener" :href="playlist.url">
{{ playlist.displayName }}
</a>
</strong>
</h4>
<div class="small-separator mb-3"></div>
<!-- eslint-disable vue/no-v-html -->
<div
class="description"
v-html="renderMarkdown(playlist.description)"
class="description wrap-text mb-3"
v-html="descriptionHTML"
/>
<!-- eslint-enable -->
<div class="metadata">
<div class="by-account">
<label v-translate>Created by</label>
<actor-miniature
type="account"
:actor="playlist.ownerAccount"
/>
<div>
<label>{{ $gettext('Created by') }}</label>
<ActorMiniature type="account" :actor="playlist.ownerAccount" />
<span class="secondary-color ms-1">{{ $gettext('and updated on') }}</span>
{{ updatedDate }}
</div>
<div class="by-channel">
<label v-translate>In</label>
<div>
<label>{{ $gettext('In channel') }}</label>
<actor-miniature
type="channel"
:actor="playlist.videoChannel"
/>
<ActorMiniature type="channel" :actor="playlist.videoChannel" />
</div>
<div class="publishedAt">
<label v-translate>Updated on</label>
<div class="mb-3">
<label>{{ $gettext('On platform') }}</label>
<div class="value">
{{ updateDate }}
</div>
<a class="peertube-link" target="_blank" rel="nofollow noreferrer noopener" :href="platformUrl">{{ host }}</a>
</div>
</div>
<div class="button">
<a
class="button-link"
target="_blank"
rel="nofollow noreferrer noopener"
:href="playlist.url"
>
<div class="button-link-container">
<a class="peertube-button peertube-primary-button peertube-button-link" target="_blank" rel="nofollow noreferrer noopener" :href="playlist.url">
{{ watchMessage }}
</a>
</div>
@ -76,98 +71,146 @@
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
import { defineComponent } from 'vue'
import ActorMiniature from './ActorMiniature.vue'
import { VideoPlaylist } from '../../../PeerTube/shared/models'
import { renderMarkdown } from '../shared/markdown-render'
export default defineComponent({
components: {
'actor-miniature': ActorMiniature
ActorMiniature
},
props: {
playlist: Object as PropType<VideoPlaylist>
playlist: Object
},
computed: {
host (): string {
const url = this.playlist.url
return new URL(url as string).host
},
updateDate (): string {
return new Date(this.playlist.updatedAt).toLocaleDateString()
},
watchMessage (): string {
return this.$gettextInterpolate(this.$gettext('Watch the playlist on %{host}'), { host: this.host })
},
videosLengthLabel (): string {
return this.$gettextInterpolate(this.$gettext('%{videosLength} videos'), { videosLength: this.playlist.videosLength })
data () {
return {
thumbnailError: false,
descriptionHTML: ''
}
},
computed: {
host () {
const url = this.playlist.url
return new URL(url).host
},
platformUrl () {
const url = this.playlist.url
const parsed = new URL(url)
return parsed.protocol + '//' + parsed.host
},
updatedDate () {
return new Date(this.playlist.updatedAt).toLocaleDateString()
},
watchMessage () {
return this.$gettext('Watch the playlist on %{host}', { host: this.host })
},
videosLengthLabel () {
return this.$ngettext('%{videosLength} video', '%{videosLength} videos', this.playlist.videosLength, { videosLength: this.playlist.videosLength })
}
},
mounted () {
this.lazyRenderMarkdown(this.playlist.description)
.then(html => this.descriptionHTML = html)
},
methods: {
renderMarkdown(markdown: string) {
return renderMarkdown(markdown)
setThumbnailError () {
this.thumbnailError = true
}
}
})
</script>
<style lang="scss">
<style lang="scss" scoped>
@import '../scss/_variables';
.playlist {
.thumbnail {
margin-right: 20px;
--thumbnail-width: #{$thumbnail-width};
--thumbnail-height: #{$thumbnail-height};
.image-container {
// For the videos length overlay
.img {
position: relative;
display: inline-block;
width: var(--thumbnail-width);
height: var(--thumbnail-height);
}
.behind-1,
.behind-2,
.behind-3 {
position: absolute;
height: 100%;
width: 100%;
border-radius: 3px;
overflow: hidden;
border: 1px solid $beige-700;
}
.behind-1 {
z-index: 3;
left: 8px;
width: calc(100% - 3px);
top: 5px;
background-color: $beige-400;
}
.behind-2 {
z-index: 2;
left: 14px;
width: calc(100% - 3px);
top: 11px;
background-color: $beige-500;
border: 1px solid $beige-700;
}
.behind-3 {
z-index: 1;
left: 20px;
width: calc(100% - 4px);
top: 17px;
background-color: $beige-700;
}
img {
width: 275px;
height: 147px;
border-radius: 3px;
position: relative;
z-index: 10;
&.error {
border: 1px solid #E5E5E5;
}
}
.videos-length {
font-size: 0.875rem;
position: absolute;
right: 0;
bottom: 0;
z-index: 15;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 10px;
justify-content: center;
padding: 0 15px;
height: 100%;
color: #fff;
background-color: rgba(0,0,0,.7);
background-color: rgba($beige-800, 0.8);
font-size: 14px;
font-weight: 600;
}
font-weight: $font-semibold;
img {
width: 100%;
height: 100%;
}
@media screen and (max-width: $small-view) {
--thumbnail-width: calc(100% + 10px);
--thumbnail-height: auto;
img {
border-radius: 0;
}
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
}
}
}

View File

@ -0,0 +1,50 @@
<script lang="ts">
import { defineComponent, h } from 'vue'
import { RouterLink } from 'vue-router'
import { parseHTML, getChildNodes, getAttribute, getText, nodeIs, getClass } from '../shared/html-parser'
export default defineComponent({
render () {
const parsed = parseHTML(this.$slots.default()[0].children as string)
const toRender = []
getChildNodes(parsed).forEach(node => {
if (nodeIs(node, 'TEXT')) {
toRender.push(getText(node))
return
}
if (nodeIs(node, 'STRONG')) {
toRender.push(h('strong', getText(node)))
return
}
if (nodeIs(node, 'EM')) {
toRender.push(h('em', getText(node)))
return
}
if (nodeIs(node, 'ROUTER-LINK')) {
toRender.push(h(RouterLink, { to: getAttribute(node, 'to'), className: getClass(node) }, () => getText(node)))
return
}
if (nodeIs(node, 'BR')) {
toRender.push(h('br'))
return
}
if (nodeIs(node, 'A')) {
toRender.push(h('a', {
target: getAttribute(node, 'target'),
href: getAttribute(node, 'href'),
rel: getAttribute(node, 'rel'),
className: getAttribute(node, 'class')
}, getText(node)))
}
})
return h('span', toRender)
}
})
</script>

View File

@ -0,0 +1,142 @@
<template>
<div>
<label for="search" :class="{ 'visually-hidden': !label }">{{ $gettext('My search') }}</label>
<div class="input-container">
<input
v-model="formSearch"
:placeholder="inputPlaceholder"
autofocus
type="text"
name="search"
autocapitalize="off"
autocomplete="off"
autocorrect="off"
maxlength="1024"
@keydown.enter="search"
>
<router-link class="peertube-button peertube-primary-button search-button" :to="{ path: '/search', query: { ...getUrlQuery() } }">
<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"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<span v-if="formSearch">{{ $gettext('Search!') }}</span>
<span v-else>{{ $gettext('Explore!') }}</span>
</router-link>
</div>
</div>
</template>
<script lang="ts">
import { SearchUrl } from '@/models/search-url.model'
import { defineComponent } from 'vue'
export default defineComponent({
props: {
label: Boolean
},
data () {
return {
formSearch: ''
}
},
computed: {
inputPlaceholder (): string {
return this.$gettext('Keyword, channel, video, playlist, etc.')
}
},
mounted () {
this.loadUrl()
},
methods: {
getUrlQuery (): SearchUrl {
return { ...this.$route.query, search: this.formSearch }
},
loadUrl () {
const query = this.$route.query as SearchUrl
if (query.search) this.formSearch = query.search
else this.formSearch = undefined
},
search (event: Event) {
event.preventDefault()
this.$router.push({ path: '/search', query: { ...this.getUrlQuery() } })
}
}
})
</script>
<style scoped lang="scss">
@use 'sass:math';
@import '../scss/_variables';
@import '../scss/bootstrap-mixins';
.input-container {
display: flex;
height: 45px;
margin: auto;
}
label {
font-size: 1.125rem;
display: block;
font-weight: $font-bold;
margin-bottom: 0.5rem;
}
input[type=text] {
background-color: #fff;
border-radius: 2px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
border: 0;
color: #000;
flex-grow: 1;
padding: 0 20px;
height: 100%;
}
.search-button {
text-decoration: none;
border-radius: 0 2px 2px 0;
height: 100%;
padding: 0 1rem 0 0.75rem;
display: flex;
align-items: center;
justify-content: center;
@include media-breakpoint-up(sm) {
svg {
margin-right: 10px;
}
min-width: 130px;
}
@include media-breakpoint-down(sm) {
span {
display: none;
}
}
}
</style>

View File

@ -1,16 +1,19 @@
<template>
<div
class="block-warning"
:class="{ highlight: highlight }"
>
<img
src="/img/sepia-warning.svg"
alt=""
>
<div class="block-warning" :class="{ block: isBlockMode }">
<img src="/img/sepia-warning.svg" alt="" />
<div v-translate>
<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 administrators on the PeerTube website where the video is published.
<div>
<SafeHTML>
{{
$gettext(
'<strong>%{indexName}</strong> displays videos and channels that match your search but is not the publisher, nor the owner.',
{ indexName: indexName },
true
)
}}
</SafeHTML>
{{ $gettext('If you notice any problems with a video, report it to the administrators on the PeerTube website where the video is published.') }}
</div>
</div>
</template>
@ -21,7 +24,7 @@
export default defineComponent({
props: {
indexName: String,
highlight: Boolean
isBlockMode: Boolean
}
})
</script>
@ -30,29 +33,26 @@
@import '../scss/_variables';
.block-warning {
font-style: italic;
color: $orange;
display: flex;
align-items: center;
margin: 20px auto;
img {
display: none;
margin-right: 20px;
height: 70px;
}
&.highlight {
img {
display: block;
}
&.block {
max-width: 800px;
color: $brown;
border: 3px solid #f4af81;
margin: auto;
border: 3px solid $beige-700;
border-radius: 10px;
font-style: normal;
padding: 10px 20px;
padding: 0.75rem 1.25rem;
img {
margin-right: 20px;
height: 70px;
display: block;
}
}
}

View File

@ -0,0 +1,104 @@
<template>
<div class="root">
<label class="d-none d-sm-inline" for="sort">{{ $gettext('Sort by:') }}</label>
<div class="peertube-button peertube-secondary-button">
<select id="sort" v-model="formSort" name="sort">
<option value="-match">{{ $gettext('Best match') }}</option>
<option value="-createdAt">{{ $gettext('Most recent') }}</option>
<option value="createdAt">{{ $gettext('Least recent') }}</option>
</select>
<span class="focus"></span>
</div>
</div>
</template>
<script lang="ts">
import { SearchUrl } from '@/models'
import { defineComponent } from 'vue'
export default defineComponent({
data () {
return {
formSort: '-match'
}
},
watch: {
formSort () {
this.updateUrl()
}
},
mounted () {
this.loadUrl()
},
methods: {
updateUrl () {
this.$router.push({ path: '/search', query: { ...this.$route.query, sort: this.formSort }})
},
loadUrl () {
const query = this.$route.query as SearchUrl
if (query.sort) this.formSort = query.sort
else this.formSort = '-match'
}
}
})
</script>
<style scoped lang="scss">
@use 'sass:math';
@import '../scss/_variables';
.peertube-secondary-button {
margin-left: 1rem;
padding: 0;
position: relative;
select {
outline: none !important;
box-shadow: none !important;
appearance: none;
background-color: transparent;
border: none;
margin: 0;
padding: 0;
font-weight: $font-bold;
font-size: 0.875rem;
cursor: pointer;
padding: 0.5rem 2rem 0.5rem 1rem;
&:focus + span {
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
border: 2px solid var(--select-focus);
border-radius: inherit;
}
}
&::after {
content: "";
width: 8px;
height: 5px;
background-color: $main-font-color;
clip-path: polygon(100% 0%, 0 0%, 50% 100%);
display: inline-block;
margin-left: 1.5rem;
margin-right: 0.75rem;
position: absolute;
right: 0;
top: calc(50% - 2px);
}
}
</style>

View File

@ -1,98 +1,64 @@
<template>
<div class="video root-result">
<div class="thumbnail">
<a
class="img"
:title="watchVideoMessage"
target="_blank"
rel="nofollow noreferrer noopener"
:href="video.url"
>
<img
:src="getVideoThumbnailUrl()"
alt=""
:class="{ error: thumbnailError }"
@error="setThumbnailError()"
>
<div class="video search-card-result">
<div class="image-container">
<a class="img" :title="watchVideoMessage" target="_blank" rel="nofollow noreferrer noopener" :href="video.url">
<img :src="getVideoThumbnailUrl()" alt="" :class="{ error: thumbnailError }" @error="setThumbnailError()">
<span
v-if="video.isLive"
v-translate
class="live-info"
>LIVE</span>
<span
v-else
class="duration"
>{{ formattedDuration }}</span>
<span v-if="video.isLive" class="live-info">{{ $gettext('LIVE') }}</span>
<span v-else class="duration">{{ formattedDuration }}</span>
</a>
</div>
<div class="information">
<h5 class="title">
<a
:title="watchVideoMessage"
target="_blank"
rel="nofollow noreferrer noopener"
:href="video.url"
>{{ video.name }}</a>
</h5>
<div class="flex-grow-1">
<h4>
<strong>
<a class="wrap-text" :title="watchVideoMessage" target="_blank" rel="nofollow noreferrer noopener" :href="video.url">
{{ video.name }}
</a>
</strong>
</h4>
<div class="small-separator mb-3"></div>
<!-- eslint-disable vue/no-v-html -->
<div
class="description"
v-html="renderMarkdown(video.description)"
/>
class="description wrap-text mb-3"
v-html="descriptionHTML"
></div>
<!-- eslint-enable -->
<div class="metadata">
<div class="by-account">
<label v-translate>Created by</label>
<actor-miniature
type="account"
:actor="video.account"
/>
</div>
<div>
<label>{{ $gettext('Published by') }}</label>
<ActorMiniature type="account" :actor="video.account" />
<div class="by-channel">
<label v-translate>In</label>
<span class="secondary-color ms-1">on</span>
<actor-miniature
type="channel"
:actor="video.channel"
/>
</div>
<div class="publishedAt">
<label v-translate>On</label>
<div class="value">
{{ publicationDate }}
</div>
<div>
<label>{{ $gettext('In channel') }}</label>
<ActorMiniature type="channel" :actor="video.channel" />
</div>
<div class="language">
<label v-translate>Language</label>
<div class="mb-3">
<label>{{ $gettext('On platform') }}</label>
<div class="value">
{{ video.language.label }}
<a class="peertube-link" target="_blank" rel="nofollow noreferrer noopener" :href="platformUrl">{{ host }}</a>
</div>
<div v-if="video.language.id">
<label>{{ $gettext('Language:') }}</label>
<span>{{ video.language.label }}</span>
</div>
</div>
<div
v-if="video.tags && video.tags.length !== 0"
class="tags"
>
<label v-translate>Tags</label>
<div class="value">
{{ video.tags.join(', ') }}
</div>
</div>
</div>
<div class="button">
<div class="button-link-container">
<a
class="button-link"
class="peertube-button peertube-primary-button peertube-button-link"
target="_blank"
rel="nofollow noreferrer noopener"
:href="video.url"
@ -105,51 +71,64 @@
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
import { defineComponent } from 'vue'
import { durationToString } from '@/shared/utils'
import ActorMiniature from './ActorMiniature.vue'
import { durationToString } from '../shared/utils'
import { renderMarkdown } from '../shared/markdown-render'
import { EnhancedVideo } from '../../../server/types/video.model'
export default defineComponent({
components: {
'actor-miniature': ActorMiniature
ActorMiniature
},
props: {
video: Object as PropType<EnhancedVideo>
video: Object
},
data () {
return {
thumbnailError: false
thumbnailError: false,
descriptionHTML: ''
}
},
computed: {
host (): string {
host () {
const url = this.video.url
return new URL(url as string).host
return new URL(url).host
},
formattedDuration (): string {
platformUrl () {
const url = this.video.url
const parsed = new URL(url)
return parsed.protocol + '//' + parsed.host
},
formattedDuration () {
return durationToString(this.video.duration)
},
windowWidth (): number {
windowWidth () {
return window.innerWidth
},
publicationDate (): string {
publicationDate () {
return new Date(this.video.publishedAt).toLocaleDateString()
},
watchVideoMessage (): string {
return this.$gettextInterpolate(this.$gettext('Watch the video on %{host}'), { host: this.host })
watchVideoMessage () {
return this.$gettext('Watch the video on %{host}', { host: this.host })
}
},
mounted () {
this.lazyRenderMarkdown(this.video.description)
.then(html => this.descriptionHTML = html)
},
methods: {
getVideoThumbnailUrl () {
if (this.windowWidth >= 900) {
@ -161,36 +140,28 @@
setThumbnailError () {
this.thumbnailError = true
},
renderMarkdown(markdown: string) {
return renderMarkdown(markdown)
}
}
})
</script>
<style lang="scss">
<style lang="scss" scoped>
@import '../scss/_variables';
.video {
.thumbnail {
margin-right: 20px;
--thumbnail-width: #{$thumbnail-width};
--thumbnail-height: #{$thumbnail-height};
.image-container {
// For the duration overlay
.img {
position: relative;
display: inline-block;
}
img {
background-color: #E5E5E5;
width: var(--thumbnail-width);
height: var(--thumbnail-height);
border-radius: 3px;
width: 275px;
height: 147px;
border-radius: 2px;
box-shadow: 4px 4px 0 0 $beige-700;
&.error {
border: 1px solid #E5E5E5;
@ -200,13 +171,14 @@
.duration,
.live-info {
position: absolute;
padding: 2px 5px;
right: 5px;
bottom: 5px;
display: inline-block;
padding: 2px 5px;
right: 0;
bottom: 0;
color: #fff;
font-size: 11px;
border-radius: 3px;
background-color: #000;
font-size: 0.75rem;
border-radius: 2px 0px 0px 0px;
}
.duration {
@ -217,15 +189,6 @@
background-color: rgba(224, 8, 8 ,.8);
font-weight: $font-semibold;
}
@media screen and (max-width: $small-view) {
--thumbnail-width: calc(100% + 10px);
--thumbnail-height: auto;
img {
border-radius: 0;
}
}
}
}
</style>

View File

@ -0,0 +1,12 @@
<template>
<svg width="57" height="57" viewBox="0 0 57 57" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="28.5" cy="28.5" r="28.5" fill="#F2690D" />
<path d="M25.762 31.4644L24.1883 10.7838H32.5449L30.9932 31.4644H25.762ZM28.422 45.4459C27.8013 45.4459 27.2102 45.3215 26.6487 45.0725C26.1019 44.8391 25.6217 44.5045 25.2079 44.0688C24.7941 43.6331 24.469 43.1274 24.2326 42.5516C23.9961 41.9603 23.8779 41.3223 23.8779 40.6376C23.8779 39.9218 24.0035 39.2682 24.2548 38.6769C24.5207 38.07 24.868 37.5487 25.2966 37.113C25.7251 36.6773 26.2201 36.335 26.7817 36.086C27.358 35.837 27.9565 35.7125 28.5771 35.7125C29.1978 35.7125 29.7815 35.837 30.3283 36.086C30.8898 36.335 31.3774 36.6773 31.7912 37.113C32.205 37.5487 32.5301 38.0622 32.7665 38.6536C33.003 39.2449 33.1212 39.8829 33.1212 40.5676C33.1212 41.2678 32.9882 41.9214 32.7222 42.5283C32.471 43.1196 32.1237 43.6331 31.6804 44.0688C31.2518 44.5045 30.7568 44.8391 30.1953 45.0725C29.6337 45.3215 29.0426 45.4459 28.422 45.4459Z" fill="white" />
</svg>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({})
</script>

13
client/src/env.d.ts vendored
View File

@ -1,8 +1,11 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>
export default component
import { Language } from 'vue3-gettext'
declare module '@vue/runtime-core' {
interface ComponentCustomProperties extends Pick<Language, "$gettext" | "$pgettext" | "$ngettext" | "$npgettext"> {
$language: Language;
lazyRenderMarkdown: (markdown: string) => Promise<string>
}
}

View File

@ -1,10 +1,13 @@
import './scss/main.scss'
import { createApp } from 'vue'
import VueMatomo from 'vue-matomo'
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHistory, RouterScrollBehavior } from 'vue-router'
import { createGettext } from 'vue3-gettext'
import App from './App.vue'
import Home from './views/Home.vue'
import Search from './views/Search.vue'
import CommonMixins from './mixins/CommonMixins'
import SafeHTML from './components/SafeHTML.vue'
const app = createApp(App)
@ -59,7 +62,7 @@ if (allLocales.includes(localePath)) {
} else if (languageFromLocalStorage) {
currentLanguage = languageFromLocalStorage
} else {
const navigatorLanguage = (window.navigator as any).userLanguage || window.navigator.language
const navigatorLanguage = window.navigator.language
const snakeCaseLanguage = navigatorLanguage.replace('-', '_')
currentLanguage = aliasesLanguages[snakeCaseLanguage] ? aliasesLanguages[snakeCaseLanguage] : snakeCaseLanguage
}
@ -79,6 +82,8 @@ buildTranslationsPromise(defaultLanguage, currentLanguage)
})
app.use(gettext)
app.mixin(CommonMixins)
app.component('SafeHTML', SafeHTML)
// Routes
let routes = []
@ -91,7 +96,7 @@ buildTranslationsPromise(defaultLanguage, currentLanguage)
routes = routes.concat([
{
path: base,
component: Search
component: Home
},
{
path: base + 'search',
@ -108,6 +113,7 @@ buildTranslationsPromise(defaultLanguage, currentLanguage)
const router = createRouter({
history: createWebHistory(),
scrollBehavior: buildScrollBehaviour(),
routes
})
@ -150,7 +156,7 @@ function buildTranslationsPromise (defaultLanguage, currentLanguage) {
if (currentLanguage === defaultLanguage) return Promise.resolve(translations)
// Fetch translations from server
const fromRemote = import('./translations/' + currentLanguage + '.json')
const fromRemote = import(`./translations/${currentLanguage}.json`)
.then(module => {
const remoteTranslations = module.default
try {
@ -176,3 +182,22 @@ function buildTranslationsPromise (defaultLanguage, currentLanguage) {
return fromRemote
}
function buildScrollBehaviour (): RouterScrollBehavior {
const isBot = /bot|googlebot|crawler|spider|robot|crawling/i.test(navigator.userAgent)
const isGoogleCache = window.location.host === 'webcache.googleusercontent.com'
if (isBot || isGoogleCache) return undefined
return (to, from, savedPosition) => {
if (savedPosition) return savedPosition
if (to.hash) return { el: to.hash }
if (to.path === from.path) return undefined
// Have to use a promise here
// FIXME: https://github.com/vuejs/router/issues/1411
return new Promise(res => setTimeout(() => {
res({ top: 0, left: 0 })
}))
}
}

View File

@ -0,0 +1,9 @@
export default {
methods: {
async lazyRenderMarkdown (markdown: string) {
const { renderMarkdown } = await import('../shared/markdown-render')
return renderMarkdown(markdown)
}
}
}

View File

@ -4,15 +4,15 @@ export interface SearchUrl {
host?: string
publishedDateRange?: string
durationRange?: string
categoryOneOf?: number[]
licenceOneOf?: number[]
categoryOneOf?: string[]
licenceOneOf?: string[]
languageOneOf?: string[]
tagsAllOf?: string[]
tagsOneOf?: string[]
isLive?: boolean | string
isLive?: string
sort?: string
page?: number | string
page?: string
}

View File

@ -0,0 +1,7 @@
@import './_bootstrap-variables';
@import 'bootstrap/scss/functions';
@import 'bootstrap/scss/variables';
@import 'bootstrap/scss/maps';
@import 'bootstrap/scss/mixins';
@import 'bootstrap/scss/utilities';

View File

@ -0,0 +1,24 @@
@import "_variables";
$primary: $orange-main;
$body-color: $main-font-color;
$link-color: $main-font-color;
$link-decoration: none;
$container-max-widths: (
sm: 540px,
md: 720px,
lg: 960px,
xl: 1140px,
xxl: 1270px
);
$grid-breakpoints: (
xs: 0,
sm: 576px,
md: 768px,
lg: 992px,
xl: 1200px,
xxl: 1400px
);

View File

@ -0,0 +1,8 @@
@import './_variables';
@import "bootstrap/scss/mixins";
@mixin all-width-block {
margin-left: -$main-horizontal-padding;
margin-right: -$main-horizontal-padding;
}

View File

@ -1,23 +1,32 @@
$font-bold: 700;
$font-semibold: 600;
$orange: #f67e08;
$orange-darken: #f1680d;
$orange-lighten: #fff8f0;
$grey: #858383;
$brown: #5e4f3a;
$main-font-color: #212529;
$secondary-font-color: #717679;
$orange-main: #f1680d;
$orange-600: #D24C00;
$beige-300: #FFFAF6;
$beige-400: #FFF5EB;
$beige-500: #FCE8DB;
$beige-600: #FCE1CF;
$beige-700: #FFC99E;
$beige-800: #FFB370;
$grey: #666;
$responsive-screen: 992px;
$small-screen: 700px;
$primary: $orange;
$secondary: $grey;
$thumbnail-width: 280px;
$thumbnail-height: 153px;
$small-font-size: 12px;
$container-width: 1024px;
$small-view: 900px;
$input-height: 30px;
$border-input-color: #C6C6C6;
$main-horizontal-padding: 1rem;

51
client/src/scss/bootstrap.scss vendored Normal file
View File

@ -0,0 +1,51 @@
@import "_bootstrap-variables";
// Configuration
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/maps";
@import "bootstrap/scss/mixins";
@import "bootstrap/scss/utilities";
// Layout & components
@import "bootstrap/scss/root";
@import "bootstrap/scss/reboot";
@import "bootstrap/scss/type";
// @import "bootstrap/scss/images";
// @import "bootstrap/scss/containers";
// @import "bootstrap/scss/grid";
// @import "bootstrap/scss/tables";
// @import "bootstrap/scss/forms";
// @import "bootstrap/scss/buttons";
// @import "bootstrap/scss/transitions";
// @import "bootstrap/scss/dropdown";
// @import "bootstrap/scss/button-group";
// @import "bootstrap/scss/nav";
// @import "bootstrap/scss/navbar";
// @import "bootstrap/scss/card";
// @import "bootstrap/scss/accordion";
// @import "bootstrap/scss/breadcrumb";
// @import "bootstrap/scss/pagination";
// @import "bootstrap/scss/badge";
// @import "bootstrap/scss/alert";
// @import "bootstrap/scss/progress";
// @import "bootstrap/scss/list-group";
// @import "bootstrap/scss/close";
// @import "bootstrap/scss/toasts";
// @import "bootstrap/scss/modal";
// @import "bootstrap/scss/tooltip";
@import "bootstrap/scss/popover";
// @import "bootstrap/scss/carousel";
// @import "bootstrap/scss/spinners";
// @import "bootstrap/scss/offcanvas";
// @import "bootstrap/scss/placeholders";
// Helpers
@import "bootstrap/scss/helpers";
// Utilities
@import "bootstrap/scss/utilities/api";
.fs-7 {
font-size: 0.875rem !important;
}

View File

@ -0,0 +1,301 @@
@use 'sass:color';
@import '_variables';
@import '_mixins';
@import '_bootstrap-mixins';
.peertube-radio-container {
[type=radio]:checked,
[type=radio]:not(:checked) {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
[type=radio]:checked + label,
[type=radio]:not(:checked) + label {
position: relative;
padding-left: 28px;
cursor: pointer;
line-height: 20px;
display: inline-block;
font-weight: normal
}
[type=radio]:checked + label::before,
[type=radio]:not(:checked) + label::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 14px;
height: 14px;
border: 1px solid #C6C6C6;
border-radius: 100%;
background: #fff;
}
[type=radio]:checked + label::after,
[type=radio]:not(:checked) + label::after {
content: '';
width: 8px;
height: 8px;
background: $orange-main;
position: absolute;
top: 3px;
left: 3px;
border-radius: 100%;
transition: all 0.2s ease;
}
[type=radio]:not(:checked) + label::after {
opacity: 0;
transform: scale(0);
}
[type=radio]:checked + label::after {
opacity: 1;
transform: scale(1);
}
}
// ---------------------------------------------------------------------------
.peertube-button {
padding: 0.5rem 1rem;
display: inline-block;
font-weight: $font-bold;
border-radius: 6px;
text-align: center;
border: 2px solid $orange-main;
transition: opacity 0.2s;
font-size: 0.875rem;
cursor: pointer;
line-height: 1.35;
&:focus-visible {
box-shadow: 0 0 0 0.25rem $main-font-color;
}
}
.peertube-primary-button {
border: 2px solid $orange-main;
color: #fff;
background: $orange-main;
&:hover {
opacity: 0.8 !important;
color: #fff;
}
}
.peertube-secondary-button {
color: $main-font-color;
background: transparent;
&:hover {
opacity: 0.8;
background: rgb(242, 105, 13, 0.1);
color: $main-font-color;
}
}
.peertube-button-link {
&, &:hover, &:focus, &:active {
text-decoration: none !important;
}
}
// ---------------------------------------------------------------------------
.select-container {
padding: 0;
margin: 0;
width: 100%;
border-radius: 3px;
position: relative;
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 $border-input-color;
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;
}
}
}
.classic-input-text {
display: block;
min-height: 30px;
width: 100%;
border: 1px solid $border-input-color;
padding: 0 35px 0 12px;
outline: 0;
}
.ti-tag {
background: $orange-600 !important;
}
// ---------------------------------------------------------------------------
.peertube-link {
color: $main-font-color;
font-weight: $font-bold;
border-bottom: 3px solid $orange-main;
transition: border-bottom-width 0.2s ease;
text-decoration: none;
transition: color 0.1s;
&:hover {
text-decoration: none;
color: color.scale($main-font-color, $lightness: +20%);
}
}
// ---------------------------------------------------------------------------
.card {
@include padding(2.5rem);
background-color: $beige-500;
border: 1px solid $beige-700;
border-radius: 16px;
@include media-breakpoint-down(sm) {
@include all-width-block;
@include padding(2.5rem 1rem);
border: none;
border-radius: 0;
}
}
.search-card-result {
@include padding(1.5rem);
display: flex;
background-color: #fff;
border: 1px solid $beige-700;
border-radius: 4px;
h4 {
color: $main-font-color;
font-size: 1.25rem;
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
a {
text-decoration: none;
color: inherit;
&:hover {
opacity: 0.8;
}
}
& + .small-separator {
margin-bottom: 1rem;
height: 3px;
width: 25px;
}
}
.image-container {
margin-right: 2rem;
flex-shrink: 0;
}
.metadata,
.description {
font-size: 0.875rem;
}
.metadata {
label {
color: $secondary-font-color;
margin-top: 0.25rem;
margin-right: 0.5rem;
}
}
.button-link-container {
text-align: right;
margin-top: 1rem;
}
@include media-breakpoint-down(sm) {
@include all-width-block;
border-right: 0;
border-left: 0;
border-radius: 0;
flex-direction: column;
.image-container {
margin-right: 0;
}
h4 {
margin-top: 2rem;
}
.button-link-container {
text-align: left;
}
}
}
// ---------------------------------------------------------------------------
.muted {
color: $grey;
}
.wrap-text {
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
}
.small-separator {
width: 50px;
height: 6px;
background: #f2690d;
border-radius: 4px;
}

View File

@ -1,244 +1,63 @@
@import "_variables";
@import "./progress";
@use 'sass:color';
$border-input-color: #C6C6C6;
@import '_variables';
@import 'bootstrap.scss';
@import './progress';
@import './classes';
* {
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;
color: #000;
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;
position: relative;
@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 $border-input-color;
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;
}
}
}
.classic-input-text {
display: block;
min-height: 30px;
width: 100%;
border: 1px solid $border-input-color;
padding: 0 35px 0 12px;
*:focus-visible {
box-shadow: 0 0 0 0.25rem $orange-main;
outline: 0;
}
.results {
.root-result {
display: flex;
margin: 30px 0;
body {
font-family: -apple-system, BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
line-height: 1.58;
background-color: #FFFAF6;
color: $main-font-color;
}
.information {
width: 100%;
img {
max-width: 100%;
}
.title-block {
.container {
@include padding(4rem $main-horizontal-padding);
> *{
display: inline-block;
max-width: $container-width;
margin: auto;
position: relative;
}
.handle {
font-size: $small-font-size;
color: $grey;
input[type=text],
.vue-tags-input {
font-size: 0.875rem;
&.ti-focus,
&:focus {
box-shadow: 0 0 0 .25rem $beige-600 !important;
}
&:focus-visible {
outline: 0;
box-shadow: 0 0 0 .15rem $orange-main !important;
}
::placeholder {
color: #666;
}
}
.title {
font-size: 24px;
font-weight: bold;
margin: 0 5px 0 0;
word-break: break-word;
.vue-tags-input {
max-width: unset !important;
a {
color: #000;
text-decoration: none;
&:hover {
opacity: 0.7;
input[type=text] {
box-shadow: none !important;
}
}
}
.additional-information {
margin: 5px 0 20px 0;
font-size: $small-font-size;
color: $grey;
}
.description {
margin: 10px 0 20px 0;
border-left: 1px solid #dee2e6;
padding-left: 10px;
word-break: break-word;
a {
color: #0056b3;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.metadata {
> 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;
}
.metadata {
label {
min-width: 70px;
margin-right: 10px;
align-items: center;
font-weight: bold;
}
}
}
}
.ti-tag {
background: $orange-darken !important;
}

View File

@ -6,7 +6,7 @@
}
#nprogress .bar {
background: $orange-darken;
background: $orange-600;
position: fixed;
z-index: 1031;
@ -24,7 +24,7 @@
right: 0px;
width: 100px;
height: 100%;
box-shadow: 0 0 10px $orange-darken, 0 0 5px $orange-darken;
box-shadow: 0 0 10px $orange-600, 0 0 5px $orange-600;
opacity: 1.0;
-webkit-transform: rotate(3deg) translate(0px, -4px);
@ -38,7 +38,7 @@
position: fixed;
z-index: 1031;
top: 15px;
right: 15px;
left: 15px;
}
#nprogress .spinner-icon {
@ -47,8 +47,8 @@
box-sizing: border-box;
border: solid 2px transparent;
border-top-color: $orange-darken;
border-left-color: $orange-darken;
border-top-color: $orange-600;
border-left-color: $orange-600;
border-radius: 50%;
-webkit-animation: nprogress-spinner 400ms linear infinite;

View File

@ -0,0 +1,40 @@
function parseHTML (html: string) {
const div = document.createElement('div')
div.innerHTML = html
return div as HTMLDivElement
}
function getChildNodes (node: HTMLElement) {
return node.childNodes as NodeListOf<HTMLElement>
}
function getAttribute (node: HTMLElement, key: string) {
return node.getAttribute(key)
}
function getClass (node: HTMLElement) {
return node.className
}
function getText (node: HTMLElement) {
return node.textContent
}
function nodeIs (node: HTMLElement, tagName: string) {
if (tagName === 'TEXT') {
return node.nodeType === 3
}
return node.tagName === tagName
}
export {
parseHTML,
getChildNodes,
getClass,
getAttribute,
getText,
nodeIs
}

View File

@ -11,36 +11,42 @@ const baseVideosPath = '/api/v1/search/videos'
const baseVideoChannelsPath = '/api/v1/search/video-channels'
const baseVideoPlaylistsPath = '/api/v1/search/video-playlists'
function searchVideos (params: VideosSearchQuery) {
const options = {
params
function searchVideos (options: VideosSearchQuery) {
const axiosOptions = {
params: {
...options,
search: options.search || undefined
}
}
if (params.search) Object.assign(options.params, { search: params.search })
return axios.get<ResultList<EnhancedVideo>>(buildApiUrl(baseVideosPath), options)
return axios.get<ResultList<EnhancedVideo>>(buildApiUrl(baseVideosPath), axiosOptions)
.then(res => res.data)
}
function searchVideoChannels (params: VideoChannelsSearchQuery) {
const options = {
params
function searchVideoChannels (options: VideoChannelsSearchQuery) {
const axiosOptions = {
params: {
...options,
search: options.search || undefined
}
}
if (params.search) Object.assign(options.params, { search: params.search })
return axios.get<ResultList<EnhancedVideoChannel>>(buildApiUrl(baseVideoChannelsPath), options)
return axios.get<ResultList<EnhancedVideoChannel>>(buildApiUrl(baseVideoChannelsPath), axiosOptions)
.then(res => res.data)
}
function searchVideoPlaylists (params: VideoPlaylistsSearchQuery) {
const options = {
params
function searchVideoPlaylists (options: VideoPlaylistsSearchQuery) {
const axiosOptions = {
params: {
...options,
search: options.search || undefined
}
}
if (params.search) Object.assign(options.params, { search: params.search })
return axios.get<ResultList<EnhancedPlaylist>>(buildApiUrl(baseVideoPlaylistsPath), options)
return axios.get<ResultList<EnhancedPlaylist>>(buildApiUrl(baseVideoPlaylistsPath), axiosOptions)
.then(res => res.data)
}

View File

@ -51,9 +51,9 @@ function durationRangeToAPIParams (durationRange: string) {
}
function publishedDateRangeToAPIParams (publishedDateRange: string) {
if (!publishedDateRange) {
return { startDate: undefined, endDate: undefined }
}
const empty = { startDate: undefined, endDate: undefined }
if (!publishedDateRange) return empty
// today
const date = new Date()
@ -75,9 +75,12 @@ function publishedDateRangeToAPIParams (publishedDateRange: string) {
date.setDate(date.getDate() - 365)
break
case 'any_published_date':
return empty
default:
console.error('Unknown published date range %s', publishedDateRange)
return { startDate: undefined, endDate: undefined }
return empty
}
return { startDate: date.toISOString(), endDate: undefined }
@ -93,12 +96,45 @@ function extractTagsFromQuery <T> (value: T | T[]) {
return [ { text: value } ]
}
function extractQueryToStringArray (value: string | string[]) {
if (!value) return undefined
if (Array.isArray(value)) return value
return [ value ]
}
function extractQueryToIntArray (value: string | string[]) {
if (!value) return undefined
if (Array.isArray(value)) return value.map(v => parseInt(v))
return [ parseInt(value) ]
}
function extractQueryToInt (value: string) {
if (!value) return undefined
return parseInt(value)
}
function extractQueryToBoolean (value: string) {
if (value === 'true') return true
if (value === 'false') return false
return undefined
}
export {
buildApiUrl,
durationToString,
publishedDateRangeToAPIParams,
pageToAPIParams,
durationRangeToAPIParams,
extractTagsFromQuery
extractTagsFromQuery,
extractQueryToStringArray,
extractQueryToIntArray,
extractQueryToInt,
extractQueryToBoolean
}

129
client/src/views/Home.vue Normal file
View File

@ -0,0 +1,129 @@
<template>
<div>
<my-header :index-name="indexName" :small-format="false" />
<main>
<div class="card search-home">
<img :src="searchImageUrl" alt="" />
<form id="search-anchor" role="search" onsubmit="return false;">
<search-input :label="false"></search-input>
</form>
<h3>
<SafeHTML>
{{
$gettext(
'Search for your favorite videos, channels and playlists on <a class="peertube-link" href="%{indexedInstancesUrl}" target="_blank">%{instancesCount} PeerTube websites</a> indexed by %{indexName}!',
{ instancesCount: instancesCount, indexedInstancesUrl: indexedInstancesUrl, indexName: indexName },
true
)
}}
</SafeHTML>
</h3>
</div>
<search-warning class="search-warning" :index-name="indexName" :is-block-mode="true" />
</main>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import Header from '../components/Header.vue'
import SearchWarning from '../components/SearchWarning.vue'
import SearchInput from '../components/SearchInput.vue'
import { getConfig } from '../shared/config'
import { buildApiUrl } from '@/shared/utils'
export default defineComponent({
components: {
'search-input': SearchInput,
'search-warning': SearchWarning,
'my-header': Header
},
data () {
return {
instancesCount: undefined,
indexedInstancesUrl: '',
indexName: '',
formSearch: '',
searchImageUrl: '',
introSentence: ''
}
},
async mounted () {
const config = await getConfig()
this.instancesCount = config.indexedHostsCount
this.indexedInstancesUrl = config.indexedInstancesUrl
this.indexName = config.searchInstanceName
this.searchImageUrl = config.searchInstanceSearchImage
? buildApiUrl(config.searchInstanceSearchImage)
: buildApiUrl('/img/sepia-search.svg')
}
})
</script>
<style scoped lang="scss">
@use 'sass:math';
@import '../scss/_variables';
@import '../scss/bootstrap-mixins';
main {
margin: auto;
}
h3 {
@include margin(2rem auto 0 auto);
max-width: 600px;
text-align: center;
font-weight: normal;
font-size: 1rem;
line-height: inherit;
a {
color: $orange-main;
&:hover {
color: $orange-600;
}
}
}
.card {
@include margin-bottom(4rem);
img {
display: block;
width: 290px;
height: 250px;
margin: 0 auto 1rem auto;
}
}
form {
max-width: 700px;
margin: auto;
}
.search-home img {
position: relative;
bottom: -8px;
margin-bottom: 0;
z-index: 100;
@include media-breakpoint-down(sm) {
width: 200px;
height: auto;
bottom: -5px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,8 @@
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true,
"lib": ["esnext", "dom"],
"paths": {
"@shared/*": [ "../PeerTube/shared/*" ],

View File

@ -1,10 +1,19 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
visualizer({ open: true })
],
server: {
port: 8080
},
resolve:{
alias: {
'@': resolve(__dirname, 'src')
}
}
})

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@ elastic-search:
search-instance:
name_image: '/theme/framasoft/img/title.svg'
search_image: '/theme/framasoft/img/sepia-search.svg'
theme: 'framasoft'
videos-search:

View File

@ -65,6 +65,7 @@
"eslint": "^8.16.0",
"eslint-config-standard-with-typescript": "^24.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-n": "^15.6.0",
"eslint-plugin-node": "^11.0.0",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-standard": "^5.0.0",

View File

@ -1,37 +0,0 @@
header {
font-family: inherit !important;
margin-bottom: 0;
}
header h4 {
font-size: 16px;
color: #757575;
}
header h4 a {
color: #757575;
}
header .search-home {
margin: 50px 0 -7px 0 !important;
z-index: 100;
}
@media screen and (max-width: 500px) {
header .search-home {
margin-top: 30px !important;
margin-bottom: -4px !important;
width: 150px;
height: auto;
}
}
.search-container {
margin-top: 0;
}
@media screen and (max-height: 400px) {
.search-container {
margin-top: 30px;
}
}

View File

@ -553,6 +553,13 @@ buffer@^6.0.3:
base64-js "^1.3.1"
ieee754 "^1.2.1"
builtins@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/builtins/-/builtins-5.0.1.tgz#87f6db9ab0458be728564fa81d876d8d74552fa9"
integrity sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==
dependencies:
semver "^7.0.0"
busboy@^1.0.0, busboy@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893"
@ -886,6 +893,14 @@ eslint-plugin-es@^3.0.0:
eslint-utils "^2.0.0"
regexpp "^3.0.0"
eslint-plugin-es@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-es/-/eslint-plugin-es-4.1.0.tgz#f0822f0c18a535a97c3e714e89f88586a7641ec9"
integrity sha512-GILhQTnjYE2WorX5Jyi5i4dz5ALWxBIdQECVQavL6s7cI76IZTDWleTHkxz/QT3kvcs2QlGHvKLYsSlPOlPXnQ==
dependencies:
eslint-utils "^2.0.0"
regexpp "^3.0.0"
eslint-plugin-import@^2.26.0:
version "2.26.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz#f812dc47be4f2b72b478a021605a59fc6fe8b88b"
@ -905,6 +920,20 @@ eslint-plugin-import@^2.26.0:
resolve "^1.22.0"
tsconfig-paths "^3.14.1"
eslint-plugin-n@^15.6.0:
version "15.6.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-15.6.0.tgz#cfb1d2e2e427d620eb9008f8b3b5a40de0c84120"
integrity sha512-Hd/F7wz4Mj44Jp0H6Jtty13NcE69GNTY0rVlgTIj1XBnGGVI6UTdDrpE6vqu3AHo07bygq/N+7OH/lgz1emUJw==
dependencies:
builtins "^5.0.1"
eslint-plugin-es "^4.1.0"
eslint-utils "^3.0.0"
ignore "^5.1.1"
is-core-module "^2.11.0"
minimatch "^3.1.2"
resolve "^1.22.1"
semver "^7.3.8"
eslint-plugin-node@^11.0.0:
version "11.1.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz#c95544416ee4ada26740a30474eefc5402dc671d"
@ -1549,7 +1578,7 @@ is-callable@^1.1.4, is-callable@^1.2.7:
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==
is-core-module@^2.8.1, is-core-module@^2.9.0:
is-core-module@^2.11.0, is-core-module@^2.8.1, is-core-module@^2.9.0:
version "2.11.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144"
integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==
@ -2334,7 +2363,7 @@ resolve-from@^4.0.0:
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
resolve@^1.10.1, resolve@^1.20.0, resolve@^1.22.0:
resolve@^1.10.1, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.22.1:
version "1.22.1"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
@ -2406,7 +2435,7 @@ semver@^6.1.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
semver@^7.3.7:
semver@^7.0.0, semver@^7.3.7, semver@^7.3.8:
version "7.3.8"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==