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

View File

@ -12,7 +12,11 @@
], ],
"rules": { "rules": {
"vue/multi-word-component-names": "off", "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", "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/**/*~ src/locale/**/*~
dist/ dist/
stats.html

View File

@ -12,25 +12,29 @@
}, },
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"@babel/types": "^7.20.5",
"@popperjs/core": "^2.11.6",
"@sipec/vue3-tags-input": "^3.0.4", "@sipec/vue3-tags-input": "^3.0.4",
"@types/axios": "^0.14.0", "@types/axios": "^0.14.0",
"@types/markdown-it": "^12.2.3", "@types/markdown-it": "^12.2.3",
"@typescript-eslint/eslint-plugin": "^5.12.1", "@typescript-eslint/eslint-plugin": "^5.12.1",
"@typescript-eslint/parser": "^5.12.1", "@typescript-eslint/parser": "^5.12.1",
"@vitejs/plugin-vue": "^2.2.2", "@vitejs/plugin-vue": "^4.0.0",
"@vue/eslint-config-typescript": "^10.0.0", "@vue/eslint-config-typescript": "^11.0.2",
"axios": "^0.26.0", "axios": "^1.2.1",
"bootstrap": "^5.2.3",
"eslint": "^8.9.0", "eslint": "^8.9.0",
"eslint-plugin-vue": "^8.5.0", "eslint-plugin-vue": "^9.8.0",
"markdown-it": "^12.3.2", "markdown-it": "^13.0.1",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"rollup-plugin-visualizer": "^5.8.3",
"sass": "^1.49.8", "sass": "^1.49.8",
"typescript": "~4.5.5", "typescript": "~4.9.4",
"vite": "^2.8.4", "vite": "^4.0.1",
"vue": "^3.2.31", "vue": "^3.2.31",
"vue-matomo": "^4.1.0", "vue-matomo": "^4.1.0",
"vue-router": "^4.0.12", "vue-router": "^4.0.12",
"vue-tsc": "^0.31.4", "vue-tsc": "^1.0.13",
"vue3-gettext": "2.2.0-alpha.1" "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> <template>
<a <span class="actor">
:href="actor.url" <img v-if="avatarUrl && !avatarError" :src="avatarUrl" alt="" :class="{ account: isAccount }" @error="setAvatarError()" />
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> <a
:href="actor.url" :title="linkTitle"
<span class="actor-handle"> class="peertube-link wrap-text" rel="nofollow noreferrer noopener" target="_blank"
{{ actor.name }}@{{ actor.host }} >{{ actor.displayName }}</a>
</span> </span>
</a>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from 'vue' import { defineComponent } from 'vue'
import { AccountSummary, VideoChannelSummary } from '../../../PeerTube/shared/models'
export default defineComponent({ export default defineComponent({
props: { props: {
actor: Object as PropType<AccountSummary | VideoChannelSummary>, actor: Object,
type: String type: String
}, },
@ -39,7 +25,7 @@
}, },
computed: { computed: {
avatarUrl (): string { avatarUrl () {
const avatars = this.actor.avatars const avatars = this.actor.avatars
if (avatars.length === 0) return '' if (avatars.length === 0) return ''
@ -53,13 +39,13 @@
return avatars[0].url return avatars[0].url
}, },
linkTitle (): string { linkTitle () {
if (this.type === 'channel') return this.$gettext('Go on this channel page') if (this.type === 'channel') return this.$gettext('Go on this channel page')
return this.$gettext('Go on this account page') return this.$gettext('Go on this account page')
}, },
isAccount (): boolean { isAccount () {
return this.type === 'account' return this.type === 'account'
} }
}, },
@ -72,49 +58,24 @@
}) })
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
@import '../scss/_variables'; @import '../scss/_variables';
.actor { .actor {
font-size: inherit; $size: 20px;
color: #000;
text-decoration: none;
display: flex;
flex-wrap: wrap;
&:hover {
text-decoration: underline;
}
img { img {
object-fit: cover; object-fit: cover;
width: 20px; width: $size;
height: 20px; height: $size;
min-width: 20px; min-width: $size;
min-height: 20px; min-height: $size;
margin-right: 5px; margin-right: 5px;
&.account { &.account {
border-radius: 50%; 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> </style>

View File

@ -1,55 +1,42 @@
<template> <template>
<div class="channel root-result"> <div class="channel search-card-result">
<a
target="_blank" <a class="image-container" target="_blank" rel="nofollow noreferrer noopener" :href="channel.url" :title="discoverChannelMessage">
rel="nofollow noreferrer noopener" <img v-if="hasAvatar()" :src="getAvatarUrl()" alt="" />
:href="channel.url" <img v-else src="/img/default-avatar.png" alt="" />
:title="discoverChannelMessage"
class="avatar"
>
<img
v-if="channel.avatar"
:src="channel.avatar.url"
alt=""
>
<img
v-else
src="/img/default-avatar.png"
alt=""
>
</a> </a>
<div class="information"> <div class="flex-grow-1">
<div class="title-block"> <h4>
<h5 class="title"> <strong>
<a <a target="_blank" rel="nofollow noreferrer noopener" class="wrap-text" :href="channel.url" :title="discoverChannelMessage">
target="_blank"
rel="nofollow noreferrer noopener"
:href="channel.url"
:title="discoverChannelMessage"
>
{{ channel.displayName }} {{ channel.displayName }}
</a> </a>
</h5> </strong>
</h4>
<span class="handle">{{ channel.name }}@{{ channel.host }}</span> <div class="small-separator"></div>
</div>
<div class="additional-information"> <!-- eslint-disable vue/no-v-html -->
<div class="followers-count"> <div
{{ followersCountMessage }} 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> </div>
<div class="description"> <div class="button-link-container">
{{ channel.description }}
</div>
<div class="button">
<a <a
class="button-link" class="peertube-button peertube-primary-button peertube-button-link"
rel="nofollow noreferrer noopener"
target="_blank" target="_blank"
rel="nofollow noreferrer noopener"
:href="channel.url" :href="channel.url"
> >
{{ discoverChannelMessage }} {{ discoverChannelMessage }}
@ -60,51 +47,89 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from 'vue' import { defineComponent } from 'vue'
import { VideoChannel } from '../../../PeerTube/shared/models'
export default defineComponent({ export default defineComponent({
props: { props: {
channel: Object as PropType<VideoChannel> channel: Object
},
data () {
return {
descriptionHTML: ''
}
}, },
computed: { computed: {
host (): string { host () {
const url = this.channel.url const url = this.channel.url
return new URL(url as string).host return new URL(url).host
}, },
discoverChannelMessage (): string { platformUrl () {
return this.$gettextInterpolate(this.$gettext('Discover this channel on %{host}'), { host: this.host }) const url = this.channel.url
const parsed = new URL(url)
return parsed.protocol + '//' + parsed.host
}, },
followersCountMessage (): string { discoverChannelMessage () {
const translated = this.$ngettext('%{ n } follower', '%{ n } followers', this.channel.followersCount) 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> </script>
<style lang="scss"> <style lang="scss" scoped>
@import '../scss/_variables'; @import '../scss/_variables';
.channel { .channel {
.avatar { $size: 140px;
width: $thumbnail-width;
min-width: $thumbnail-width;
margin-right: 20px;
display: flex;
justify-content: center;
.image-container {
img { img {
object-fit: cover; object-fit: cover;
width: 110px; max-width: $size;
height: 110px; max-height: $size;
min-width: 110px; min-width: $size;
min-height: 110px; 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> <template>
<footer id="main-footer"> <footer id="main-footer">
<div class="header"> <div class="text-center">
<img <img src="/img/bottom-peertube-logo.svg" alt="PeerTube">
src="/img/bottom-peertube-logo.svg"
alt=""
>
<div <div class="mt-3">
v-translate {{ $gettext('A free software to take back control of your videos') }}
class="description"
>
A free software to take back control of your videos
</div> </div>
</div> </div>
<div class="columns"> <div class="d-flex mt-4 justify-content-center mb-5 fs-7">
<div> <a class="peertube-link text-center mx-3" href="https://joinpeertube.org" target="_blank">{{ $gettext('Learn more about PeerTube') }}</a>
<div
v-translate
class="subtitle"
>
Open your own videos website with PeerTube!
</div>
<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>
v-translate
target="_blank"
href="https://docs.joinpeertube.org/#/install-any-os"
>Install PeerTube</a>
<a <a v-if="legalNoticesUrl" :href="legalNoticesUrl" target="_blank">{{ $gettext('Legal notices') }}</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>
</div> </div>
</footer> </footer>
</template> </template>
@ -104,115 +39,18 @@
<style lang="scss"> <style lang="scss">
@import '../scss/_variables'; @import '../scss/_variables';
@import '../scss/bootstrap-mixins';
footer { footer {
width: $container-width;
margin: auto; margin: auto;
background-color: $orange-lighten;
padding: 30px 100px 10px 100px;
font-family: monospace; font-family: monospace;
.header { > *:first-child {
margin: 50px; @include margin(2rem);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
img { img {
width: 100%; width: 100%;
max-width: 500px; 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" /> <interface-language-dropdown class="interface-language-dropdown" />
<h1> <h1>
<span v-if="configLoaded && !titleImageUrl">{{ indexName }}</span> <router-link to="/" :title="$gettext('Come back to homepage')">
<span v-if="configLoaded && !titleImageUrl">{{ indexName }}</span>
<img <img v-else class="title-image" :src="titleImageUrl" :alt="indexName" />
class="title-image" </router-link>
:src="titleImageUrl"
:alt="indexName"
>
</h1> </h1>
<template v-if="!smallFormat"> <template v-if="!smallFormat">
<h4> <h4>
<div v-translate> <SafeHTML>
A search engine of <a {{
href="https://joinpeertube.org" $gettext('A search engine of <a class="peertube-link" href="https://joinpeertube.org" target="_blank">PeerTube</a> videos, channels and playlists', {}, true)
target="_blank" }}
>PeerTube</a> videos, channels and playlists </SafeHTML>
</div>
<div v-translate> <br />
Developed by <a
href="https://framasoft.org" <SafeHTML>
target="_blank" {{
>Framasoft</a> $gettext('Developed by <a class="peertube-link" href="https://framasoft.org" target="_blank">Framasoft</a>', {}, true)
</div> }}
</SafeHTML>
</h4> </h4>
<div class="search-home">
<img
v-if="searchImageUrl"
:src="searchImageUrl"
alt=""
>
</div>
</template> </template>
</header> </header>
</div> </div>
@ -62,7 +52,6 @@
return { return {
configLoaded: false, configLoaded: false,
titleImageUrl: '', titleImageUrl: '',
searchImageUrl: ''
} }
}, },
@ -73,10 +62,6 @@
? buildApiUrl(config.searchInstanceNameImage) ? buildApiUrl(config.searchInstanceNameImage)
: '' : ''
this.searchImageUrl = config.searchInstanceSearchImage
? buildApiUrl(config.searchInstanceSearchImage)
: buildApiUrl('/img/search-home.png')
this.configLoaded = true this.configLoaded = true
} }
}) })
@ -84,14 +69,15 @@
<style lang="scss"> <style lang="scss">
@import '../scss/_variables'; @import '../scss/_variables';
@import '../scss/bootstrap-mixins.scss';
header { header {
@include margin-bottom(3rem);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 30px;
font-family: monospace;
.interface-language-dropdown { .interface-language-dropdown {
width: 20px; width: 20px;
@ -100,69 +86,32 @@
top: 10px; top: 10px;
} }
.search-home {
width: 300px;
height: 250px;
margin: 30px 0 0 0;
img {
width: inherit;
height: inherit;
}
}
h1 { h1 {
font-size: 50px; @include font-size(3rem);
// Because it's loaded dynamically // Because it's loaded dynamically
min-height: 70px; min-height: 70px;
margin: 0; margin: 0;
text-align: center; text-align: center;
} }
.title-image { .title-image {
max-width: 500px; width: 500px;
height: 78px; height: 78px;
} }
h4 { h4 {
@include font-size(1.25rem);
font-weight: normal; font-weight: normal;
margin: 0;
text-align: center; text-align: center;
line-height: 25px; line-height: inherit;
margin: 1rem 0 0;
a { @include media-breakpoint-down(sm) {
color: #000; @include font-size(1rem);
font-weight: $font-semibold;
} }
} }
@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> </style>

View File

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

View File

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

View File

@ -1,73 +1,68 @@
<template> <template>
<div class="playlist root-result"> <div class="playlist search-card-result">
<div class="thumbnail"> <div class="image-container">
<a <a class="img" :title="watchMessage" target="_blank" rel="nofollow noreferrer noopener" :href="playlist.url">
class="img" <img :src="playlist.thumbnailUrl" alt="" :class="{ error: thumbnailError }" @error="setThumbnailError()">
:title="watchMessage"
target="_blank"
rel="nofollow noreferrer noopener"
:href="playlist.url"
>
<img
:src="playlist.thumbnailUrl"
alt=""
>
<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> </a>
</div> </div>
<div class="information"> <div class="flex-grow-1">
<h5 class="title"> <h4>
<a <strong>
:title="watchMessage" <a class="wrap-text" :title="watchMessage" target="_blank" rel="nofollow noreferrer noopener" :href="playlist.url">
target="_blank" {{ playlist.displayName }}
rel="nofollow noreferrer noopener" </a>
:href="playlist.url" </strong>
>{{ playlist.displayName }}</a> </h4>
</h5>
<div class="small-separator mb-3"></div>
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<div <div
class="description" class="description wrap-text mb-3"
v-html="renderMarkdown(playlist.description)" v-html="descriptionHTML"
/> />
<!-- eslint-enable --> <!-- eslint-enable -->
<div class="metadata"> <div class="metadata">
<div class="by-account"> <div>
<label v-translate>Created by</label> <label>{{ $gettext('Created by') }}</label>
<actor-miniature <ActorMiniature type="account" :actor="playlist.ownerAccount" />
type="account"
:actor="playlist.ownerAccount" <span class="secondary-color ms-1">{{ $gettext('and updated on') }}</span>
/>
{{ updatedDate }}
</div> </div>
<div class="by-channel"> <div>
<label v-translate>In</label> <label>{{ $gettext('In channel') }}</label>
<actor-miniature <ActorMiniature type="channel" :actor="playlist.videoChannel" />
type="channel"
:actor="playlist.videoChannel"
/>
</div> </div>
<div class="publishedAt"> <div class="mb-3">
<label v-translate>Updated on</label> <label>{{ $gettext('On platform') }}</label>
<div class="value"> <a class="peertube-link" target="_blank" rel="nofollow noreferrer noopener" :href="platformUrl">{{ host }}</a>
{{ updateDate }}
</div>
</div> </div>
</div> </div>
<div class="button"> <div class="button-link-container">
<a <a class="peertube-button peertube-primary-button peertube-button-link" target="_blank" rel="nofollow noreferrer noopener" :href="playlist.url">
class="button-link"
target="_blank"
rel="nofollow noreferrer noopener"
:href="playlist.url"
>
{{ watchMessage }} {{ watchMessage }}
</a> </a>
</div> </div>
@ -76,98 +71,146 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from 'vue' import { defineComponent } from 'vue'
import ActorMiniature from './ActorMiniature.vue' import ActorMiniature from './ActorMiniature.vue'
import { VideoPlaylist } from '../../../PeerTube/shared/models'
import { renderMarkdown } from '../shared/markdown-render'
export default defineComponent({ export default defineComponent({
components: { components: {
'actor-miniature': ActorMiniature ActorMiniature
}, },
props: { props: {
playlist: Object as PropType<VideoPlaylist> playlist: Object
}, },
computed: { data () {
host (): string { return {
const url = this.playlist.url thumbnailError: false,
descriptionHTML: ''
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 })
} }
}, },
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: { methods: {
renderMarkdown(markdown: string) { setThumbnailError () {
return renderMarkdown(markdown) this.thumbnailError = true
} }
} }
}) })
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
@import '../scss/_variables'; @import '../scss/_variables';
.playlist { .playlist {
.image-container {
.thumbnail {
margin-right: 20px;
--thumbnail-width: #{$thumbnail-width};
--thumbnail-height: #{$thumbnail-height};
// For the videos length overlay // For the videos length overlay
.img { .img {
position: relative; position: relative;
display: inline-block; display: inline-block;
width: var(--thumbnail-width);
height: var(--thumbnail-height);
border-radius: 3px;
overflow: hidden;
} }
.videos-length { .behind-1,
.behind-2,
.behind-3 {
position: absolute; position: absolute;
right: 0;
bottom: 0;
display: flex;
align-items: center;
padding: 0 10px;
height: 100%; height: 100%;
width: 100%;
border-radius: 3px;
border: 1px solid $beige-700;
}
color: #fff; .behind-1 {
background-color: rgba(0,0,0,.7); z-index: 3;
left: 8px;
width: calc(100% - 3px);
top: 5px;
background-color: $beige-400;
}
font-size: 14px; .behind-2 {
font-weight: 600; 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 { img {
width: 100%; width: 275px;
height: 100%; height: 147px;
border-radius: 3px;
position: relative;
z-index: 10;
&.error {
border: 1px solid #E5E5E5;
}
} }
@media screen and (max-width: $small-view) { .videos-length {
--thumbnail-width: calc(100% + 10px); font-size: 0.875rem;
--thumbnail-height: auto;
img { position: absolute;
border-radius: 0; right: 0;
} bottom: 0;
z-index: 15;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 15px;
height: 100%;
background-color: rgba($beige-800, 0.8);
font-weight: $font-semibold;
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> <template>
<div <div class="block-warning" :class="{ block: isBlockMode }">
class="block-warning" <img src="/img/sepia-warning.svg" alt="" />
:class="{ highlight: highlight }"
>
<img
src="/img/sepia-warning.svg"
alt=""
>
<div v-translate> <div>
<strong>%{indexName}</strong> displays videos and channels that match your search but is not the publisher, nor the owner. <SafeHTML>
If you notice any problems with a video, report it to the administrators on the PeerTube website where the video is published. {{
$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>
</div> </div>
</template> </template>
@ -21,7 +24,7 @@
export default defineComponent({ export default defineComponent({
props: { props: {
indexName: String, indexName: String,
highlight: Boolean isBlockMode: Boolean
} }
}) })
</script> </script>
@ -30,29 +33,26 @@
@import '../scss/_variables'; @import '../scss/_variables';
.block-warning { .block-warning {
font-style: italic;
color: $orange;
display: flex; display: flex;
align-items: center; align-items: center;
margin: 20px auto;
img { img {
display: none; display: none;
margin-right: 20px;
height: 70px;
} }
&.highlight { &.block {
img {
display: block;
}
max-width: 800px; max-width: 800px;
color: $brown; margin: auto;
border: 3px solid #f4af81; border: 3px solid $beige-700;
border-radius: 10px; border-radius: 10px;
font-style: normal; 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> <template>
<div class="video root-result"> <div class="video search-card-result">
<div class="thumbnail"> <div class="image-container">
<a <a class="img" :title="watchVideoMessage" target="_blank" rel="nofollow noreferrer noopener" :href="video.url">
class="img" <img :src="getVideoThumbnailUrl()" alt="" :class="{ error: thumbnailError }" @error="setThumbnailError()">
:title="watchVideoMessage"
target="_blank"
rel="nofollow noreferrer noopener"
:href="video.url"
>
<img
:src="getVideoThumbnailUrl()"
alt=""
:class="{ error: thumbnailError }"
@error="setThumbnailError()"
>
<span <span v-if="video.isLive" class="live-info">{{ $gettext('LIVE') }}</span>
v-if="video.isLive" <span v-else class="duration">{{ formattedDuration }}</span>
v-translate
class="live-info"
>LIVE</span>
<span
v-else
class="duration"
>{{ formattedDuration }}</span>
</a> </a>
</div> </div>
<div class="information"> <div class="flex-grow-1">
<h5 class="title"> <h4>
<a <strong>
:title="watchVideoMessage" <a class="wrap-text" :title="watchVideoMessage" target="_blank" rel="nofollow noreferrer noopener" :href="video.url">
target="_blank" {{ video.name }}
rel="nofollow noreferrer noopener" </a>
:href="video.url" </strong>
>{{ video.name }}</a> </h4>
</h5>
<div class="small-separator mb-3"></div>
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<div <div
class="description" class="description wrap-text mb-3"
v-html="renderMarkdown(video.description)" v-html="descriptionHTML"
/> ></div>
<!-- eslint-enable --> <!-- eslint-enable -->
<div class="metadata"> <div class="metadata">
<div class="by-account"> <div>
<label v-translate>Created by</label> <label>{{ $gettext('Published by') }}</label>
<actor-miniature <ActorMiniature type="account" :actor="video.account" />
type="account"
:actor="video.account" <span class="secondary-color ms-1">on</span>
/>
{{ publicationDate }}
</div> </div>
<div class="by-channel"> <div>
<label v-translate>In</label> <label>{{ $gettext('In channel') }}</label>
<actor-miniature <ActorMiniature type="channel" :actor="video.channel" />
type="channel"
:actor="video.channel"
/>
</div> </div>
<div class="publishedAt"> <div class="mb-3">
<label v-translate>On</label> <label>{{ $gettext('On platform') }}</label>
<div class="value"> <a class="peertube-link" target="_blank" rel="nofollow noreferrer noopener" :href="platformUrl">{{ host }}</a>
{{ publicationDate }}
</div>
</div> </div>
<div class="language"> <div v-if="video.language.id">
<label v-translate>Language</label> <label>{{ $gettext('Language:') }}</label>
<div class="value"> <span>{{ video.language.label }}</span>
{{ video.language.label }}
</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> </div>
<div class="button"> <div class="button-link-container">
<a <a
class="button-link" class="peertube-button peertube-primary-button peertube-button-link"
target="_blank" target="_blank"
rel="nofollow noreferrer noopener" rel="nofollow noreferrer noopener"
:href="video.url" :href="video.url"
@ -105,51 +71,64 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from 'vue' import { defineComponent } from 'vue'
import { durationToString } from '@/shared/utils'
import ActorMiniature from './ActorMiniature.vue' 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({ export default defineComponent({
components: { components: {
'actor-miniature': ActorMiniature ActorMiniature
}, },
props: { props: {
video: Object as PropType<EnhancedVideo> video: Object
}, },
data () { data () {
return { return {
thumbnailError: false thumbnailError: false,
descriptionHTML: ''
} }
}, },
computed: { computed: {
host (): string { host () {
const url = this.video.url 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) return durationToString(this.video.duration)
}, },
windowWidth (): number { windowWidth () {
return window.innerWidth return window.innerWidth
}, },
publicationDate (): string { publicationDate () {
return new Date(this.video.publishedAt).toLocaleDateString() return new Date(this.video.publishedAt).toLocaleDateString()
}, },
watchVideoMessage (): string { watchVideoMessage () {
return this.$gettextInterpolate(this.$gettext('Watch the video on %{host}'), { host: this.host }) return this.$gettext('Watch the video on %{host}', { host: this.host })
} }
}, },
mounted () {
this.lazyRenderMarkdown(this.video.description)
.then(html => this.descriptionHTML = html)
},
methods: { methods: {
getVideoThumbnailUrl () { getVideoThumbnailUrl () {
if (this.windowWidth >= 900) { if (this.windowWidth >= 900) {
@ -161,36 +140,28 @@
setThumbnailError () { setThumbnailError () {
this.thumbnailError = true this.thumbnailError = true
},
renderMarkdown(markdown: string) {
return renderMarkdown(markdown)
} }
} }
}) })
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
@import '../scss/_variables'; @import '../scss/_variables';
.video { .video {
.image-container {
.thumbnail {
margin-right: 20px;
--thumbnail-width: #{$thumbnail-width};
--thumbnail-height: #{$thumbnail-height};
// For the duration overlay // For the duration overlay
.img { .img {
position: relative; position: relative;
display: inline-block;
} }
img { img {
background-color: #E5E5E5; background-color: #E5E5E5;
width: var(--thumbnail-width); width: 275px;
height: var(--thumbnail-height); height: 147px;
border-radius: 3px; border-radius: 2px;
box-shadow: 4px 4px 0 0 $beige-700;
&.error { &.error {
border: 1px solid #E5E5E5; border: 1px solid #E5E5E5;
@ -200,13 +171,14 @@
.duration, .duration,
.live-info { .live-info {
position: absolute; position: absolute;
padding: 2px 5px;
right: 5px;
bottom: 5px;
display: inline-block; display: inline-block;
padding: 2px 5px;
right: 0;
bottom: 0;
color: #fff; color: #fff;
font-size: 11px; background-color: #000;
border-radius: 3px; font-size: 0.75rem;
border-radius: 2px 0px 0px 0px;
} }
.duration { .duration {
@ -217,15 +189,6 @@
background-color: rgba(224, 8, 8 ,.8); background-color: rgba(224, 8, 8 ,.8);
font-weight: $font-semibold; font-weight: $font-semibold;
} }
@media screen and (max-width: $small-view) {
--thumbnail-width: calc(100% + 10px);
--thumbnail-height: auto;
img {
border-radius: 0;
}
}
} }
} }
</style> </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" /> /// <reference types="vite/client" />
declare module '*.vue' { import { Language } from 'vue3-gettext'
import type { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types declare module '@vue/runtime-core' {
const component: DefineComponent<{}, {}, any> interface ComponentCustomProperties extends Pick<Language, "$gettext" | "$pgettext" | "$ngettext" | "$npgettext"> {
export default component $language: Language;
lazyRenderMarkdown: (markdown: string) => Promise<string>
}
} }

View File

@ -1,10 +1,13 @@
import './scss/main.scss' import './scss/main.scss'
import { createApp } from 'vue' import { createApp } from 'vue'
import VueMatomo from 'vue-matomo' import VueMatomo from 'vue-matomo'
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory, RouterScrollBehavior } from 'vue-router'
import { createGettext } from 'vue3-gettext' import { createGettext } from 'vue3-gettext'
import App from './App.vue' import App from './App.vue'
import Home from './views/Home.vue'
import Search from './views/Search.vue' import Search from './views/Search.vue'
import CommonMixins from './mixins/CommonMixins'
import SafeHTML from './components/SafeHTML.vue'
const app = createApp(App) const app = createApp(App)
@ -59,7 +62,7 @@ if (allLocales.includes(localePath)) {
} else if (languageFromLocalStorage) { } else if (languageFromLocalStorage) {
currentLanguage = languageFromLocalStorage currentLanguage = languageFromLocalStorage
} else { } else {
const navigatorLanguage = (window.navigator as any).userLanguage || window.navigator.language const navigatorLanguage = window.navigator.language
const snakeCaseLanguage = navigatorLanguage.replace('-', '_') const snakeCaseLanguage = navigatorLanguage.replace('-', '_')
currentLanguage = aliasesLanguages[snakeCaseLanguage] ? aliasesLanguages[snakeCaseLanguage] : snakeCaseLanguage currentLanguage = aliasesLanguages[snakeCaseLanguage] ? aliasesLanguages[snakeCaseLanguage] : snakeCaseLanguage
} }
@ -79,6 +82,8 @@ buildTranslationsPromise(defaultLanguage, currentLanguage)
}) })
app.use(gettext) app.use(gettext)
app.mixin(CommonMixins)
app.component('SafeHTML', SafeHTML)
// Routes // Routes
let routes = [] let routes = []
@ -91,7 +96,7 @@ buildTranslationsPromise(defaultLanguage, currentLanguage)
routes = routes.concat([ routes = routes.concat([
{ {
path: base, path: base,
component: Search component: Home
}, },
{ {
path: base + 'search', path: base + 'search',
@ -108,6 +113,7 @@ buildTranslationsPromise(defaultLanguage, currentLanguage)
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
scrollBehavior: buildScrollBehaviour(),
routes routes
}) })
@ -150,7 +156,7 @@ function buildTranslationsPromise (defaultLanguage, currentLanguage) {
if (currentLanguage === defaultLanguage) return Promise.resolve(translations) if (currentLanguage === defaultLanguage) return Promise.resolve(translations)
// Fetch translations from server // Fetch translations from server
const fromRemote = import('./translations/' + currentLanguage + '.json') const fromRemote = import(`./translations/${currentLanguage}.json`)
.then(module => { .then(module => {
const remoteTranslations = module.default const remoteTranslations = module.default
try { try {
@ -176,3 +182,22 @@ function buildTranslationsPromise (defaultLanguage, currentLanguage) {
return fromRemote 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 host?: string
publishedDateRange?: string publishedDateRange?: string
durationRange?: string durationRange?: string
categoryOneOf?: number[] categoryOneOf?: string[]
licenceOneOf?: number[] licenceOneOf?: string[]
languageOneOf?: string[] languageOneOf?: string[]
tagsAllOf?: string[] tagsAllOf?: string[]
tagsOneOf?: string[] tagsOneOf?: string[]
isLive?: boolean | string isLive?: string
sort?: 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; $font-semibold: 600;
$orange: #f67e08; $main-font-color: #212529;
$orange-darken: #f1680d; $secondary-font-color: #717679;
$orange-lighten: #fff8f0;
$grey: #858383; $orange-main: #f1680d;
$brown: #5e4f3a; $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; $responsive-screen: 992px;
$small-screen: 700px; $small-screen: 700px;
$primary: $orange;
$secondary: $grey;
$thumbnail-width: 280px; $thumbnail-width: 280px;
$thumbnail-height: 153px; $thumbnail-height: 153px;
$small-font-size: 12px;
$container-width: 1024px; $container-width: 1024px;
$small-view: 900px; $small-view: 900px;
$input-height: 30px; $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"; @use 'sass:color';
@import "./progress";
$border-input-color: #C6C6C6; @import '_variables';
@import 'bootstrap.scss';
@import './progress';
@import './classes';
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
body { *:focus-visible {
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"; box-shadow: 0 0 0 0.25rem $orange-main;
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;
outline: 0; outline: 0;
} }
.results { body {
.root-result { 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";
display: flex; line-height: 1.58;
margin: 30px 0; background-color: #FFFAF6;
color: $main-font-color;
}
img {
max-width: 100%;
}
.container {
@include padding(4rem $main-horizontal-padding);
max-width: $container-width;
margin: auto;
position: relative;
}
input[type=text],
.vue-tags-input {
font-size: 0.875rem;
&.ti-focus,
&:focus {
box-shadow: 0 0 0 .25rem $beige-600 !important;
} }
.information { &:focus-visible {
width: 100%; outline: 0;
box-shadow: 0 0 0 .15rem $orange-main !important;
} }
.title-block { ::placeholder {
color: #666;
> *{
display: inline-block;
}
.handle {
font-size: $small-font-size;
color: $grey;
}
}
.title {
font-size: 24px;
font-weight: bold;
margin: 0 5px 0 0;
word-break: break-word;
a {
color: #000;
text-decoration: none;
&:hover {
opacity: 0.7;
}
}
}
.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 { .vue-tags-input {
background: $orange-darken !important; max-width: unset !important;
input[type=text] {
box-shadow: none !important;
}
} }

View File

@ -6,7 +6,7 @@
} }
#nprogress .bar { #nprogress .bar {
background: $orange-darken; background: $orange-600;
position: fixed; position: fixed;
z-index: 1031; z-index: 1031;
@ -24,7 +24,7 @@
right: 0px; right: 0px;
width: 100px; width: 100px;
height: 100%; 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; opacity: 1.0;
-webkit-transform: rotate(3deg) translate(0px, -4px); -webkit-transform: rotate(3deg) translate(0px, -4px);
@ -38,7 +38,7 @@
position: fixed; position: fixed;
z-index: 1031; z-index: 1031;
top: 15px; top: 15px;
right: 15px; left: 15px;
} }
#nprogress .spinner-icon { #nprogress .spinner-icon {
@ -47,8 +47,8 @@
box-sizing: border-box; box-sizing: border-box;
border: solid 2px transparent; border: solid 2px transparent;
border-top-color: $orange-darken; border-top-color: $orange-600;
border-left-color: $orange-darken; border-left-color: $orange-600;
border-radius: 50%; border-radius: 50%;
-webkit-animation: nprogress-spinner 400ms linear infinite; -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 baseVideoChannelsPath = '/api/v1/search/video-channels'
const baseVideoPlaylistsPath = '/api/v1/search/video-playlists' const baseVideoPlaylistsPath = '/api/v1/search/video-playlists'
function searchVideos (params: VideosSearchQuery) { function searchVideos (options: VideosSearchQuery) {
const options = { const axiosOptions = {
params params: {
...options,
search: options.search || undefined
}
} }
if (params.search) Object.assign(options.params, { search: params.search }) return axios.get<ResultList<EnhancedVideo>>(buildApiUrl(baseVideosPath), axiosOptions)
return axios.get<ResultList<EnhancedVideo>>(buildApiUrl(baseVideosPath), options)
.then(res => res.data) .then(res => res.data)
} }
function searchVideoChannels (params: VideoChannelsSearchQuery) { function searchVideoChannels (options: VideoChannelsSearchQuery) {
const options = { const axiosOptions = {
params params: {
...options,
search: options.search || undefined
}
} }
if (params.search) Object.assign(options.params, { search: params.search }) return axios.get<ResultList<EnhancedVideoChannel>>(buildApiUrl(baseVideoChannelsPath), axiosOptions)
return axios.get<ResultList<EnhancedVideoChannel>>(buildApiUrl(baseVideoChannelsPath), options)
.then(res => res.data) .then(res => res.data)
} }
function searchVideoPlaylists (params: VideoPlaylistsSearchQuery) { function searchVideoPlaylists (options: VideoPlaylistsSearchQuery) {
const options = { const axiosOptions = {
params params: {
...options,
search: options.search || undefined
}
} }
if (params.search) Object.assign(options.params, { search: params.search }) return axios.get<ResultList<EnhancedPlaylist>>(buildApiUrl(baseVideoPlaylistsPath), axiosOptions)
return axios.get<ResultList<EnhancedPlaylist>>(buildApiUrl(baseVideoPlaylistsPath), options)
.then(res => res.data) .then(res => res.data)
} }

View File

@ -51,9 +51,9 @@ function durationRangeToAPIParams (durationRange: string) {
} }
function publishedDateRangeToAPIParams (publishedDateRange: string) { function publishedDateRangeToAPIParams (publishedDateRange: string) {
if (!publishedDateRange) { const empty = { startDate: undefined, endDate: undefined }
return { startDate: undefined, endDate: undefined }
} if (!publishedDateRange) return empty
// today // today
const date = new Date() const date = new Date()
@ -75,9 +75,12 @@ function publishedDateRangeToAPIParams (publishedDateRange: string) {
date.setDate(date.getDate() - 365) date.setDate(date.getDate() - 365)
break break
case 'any_published_date':
return empty
default: default:
console.error('Unknown published date range %s', publishedDateRange) console.error('Unknown published date range %s', publishedDateRange)
return { startDate: undefined, endDate: undefined } return empty
} }
return { startDate: date.toISOString(), endDate: undefined } return { startDate: date.toISOString(), endDate: undefined }
@ -93,12 +96,45 @@ function extractTagsFromQuery <T> (value: T | T[]) {
return [ { text: value } ] 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 { export {
buildApiUrl, buildApiUrl,
durationToString, durationToString,
publishedDateRangeToAPIParams, publishedDateRangeToAPIParams,
pageToAPIParams, pageToAPIParams,
durationRangeToAPIParams, 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, "sourceMap": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"esModuleInterop": true, "esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true,
"lib": ["esnext", "dom"], "lib": ["esnext", "dom"],
"paths": { "paths": {
"@shared/*": [ "../PeerTube/shared/*" ], "@shared/*": [ "../PeerTube/shared/*" ],

View File

@ -1,10 +1,19 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [
vue(),
visualizer({ open: true })
],
server: { server: {
port: 8080 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: search-instance:
name_image: '/theme/framasoft/img/title.svg' name_image: '/theme/framasoft/img/title.svg'
search_image: '/theme/framasoft/img/sepia-search.svg'
theme: 'framasoft' theme: 'framasoft'
videos-search: videos-search:

View File

@ -65,6 +65,7 @@
"eslint": "^8.16.0", "eslint": "^8.16.0",
"eslint-config-standard-with-typescript": "^24.0.0", "eslint-config-standard-with-typescript": "^24.0.0",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-n": "^15.6.0",
"eslint-plugin-node": "^11.0.0", "eslint-plugin-node": "^11.0.0",
"eslint-plugin-promise": "^6.0.0", "eslint-plugin-promise": "^6.0.0",
"eslint-plugin-standard": "^5.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" base64-js "^1.3.1"
ieee754 "^1.2.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: busboy@^1.0.0, busboy@^1.6.0:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" 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" eslint-utils "^2.0.0"
regexpp "^3.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: eslint-plugin-import@^2.26.0:
version "2.26.0" version "2.26.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz#f812dc47be4f2b72b478a021605a59fc6fe8b88b" 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" resolve "^1.22.0"
tsconfig-paths "^3.14.1" 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: eslint-plugin-node@^11.0.0:
version "11.1.0" version "11.1.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz#c95544416ee4ada26740a30474eefc5402dc671d" 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" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== 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" version "2.11.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144"
integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw== 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" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== 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" version "1.22.1"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== 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" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== 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" version "7.3.8"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==