Skip to content
Commits on Source (9)
......@@ -1630,6 +1630,11 @@
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"dev": true
},
"abortcontroller-polyfill": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.3.0.tgz",
"integrity": "sha512-lbWQgf+eRvku3va8poBlDBO12FigTQr9Zb7NIjXrePrhxWVKdCP2wbDl1tLDaYa18PWTom3UEWwdH13S46I+yA=="
},
"accepts": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
......@@ -10915,6 +10920,11 @@
"neo-async": "^2.6.0"
}
},
"throttle-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.1.0.tgz",
"integrity": "sha512-AOvyNahXQuU7NN+VVvOOX+uW6FPaWdAOdRP5HfwYxAfCzXTFKRMoIMk+n+po318+ktcChx+F1Dd91G3YHeMKyg=="
},
"through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
......
......@@ -10,6 +10,7 @@
"docker": "scripts/docker.sh"
},
"dependencies": {
"abortcontroller-polyfill": "^1.3.0",
"base64-js": "^1.3.1",
"body-scroll-toggle": "^0.2.0",
"date-fns": "^2.4.1",
......@@ -21,6 +22,7 @@
"reconnecting-websocket": "^4.2.0",
"supports-webp": "^2.0.1",
"text-encoder-lite": "^2.0.0",
"throttle-debounce": "^2.1.0",
"vue": "^2.6.10",
"vue-clamp": "^0.2.1",
"vue-feather-icons": "^5.0.0",
......
<template>
<header class="header d-flex no-select">
<HeaderTitle :level="level" class="header-title ma-0 mr-2"
<header class="header d-flex no-select" :class="alwaysShowTitle ? 'show-full-title' : null">
<HeaderTitle :level="level" class="header-title ma-0 mr-3"
:class="`t-${displayLevel || (6 - level) }`">{{ title }}</HeaderTitle>
<div class="header-addon" v-if="$slots.default">
<slot />
......@@ -15,6 +15,7 @@
components: { HeaderTitle },
props: {
title: String,
alwaysShowTitle: Boolean,
displayLevel: {
type: Number,
default: null
......@@ -32,13 +33,24 @@
align-items: center;
max-width: 100%;
white-space: nowrap;
&.show-full-title {
.header-title {
min-width: fit-content;
}
}
}
.header-title {
flex: 1;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
}
.header-addon {
flex: 0 1 auto;
margin-left: auto;
}
</style>
<template>
<div class="input d-flex">
<input :type="type" v-model="localValue" :aria-label="label" :list="autocompleteList ? id : null"
:autofocus="autofocus" :autocomplete="autocomplete" :placeholder="placeholder" :name="name"
v-bind="$attrs" ref="inputEl"/>
<slot/>
<datalist v-if="autocomplete" :id="id">
<option :value="option.value" v-for="option in options" :key="option.value"></option>
</datalist>
</div>
</template>
<script>
import { throttle } from 'throttle-debounce'
export default {
props: {
id: String,
name: String,
value: String,
type: String,
label: String,
autofocus: Boolean,
autocomplete: String,
autocompleteList: Function,
placeholder: String,
autocompleteThreshold: {
type: Number,
default: 2
}
},
data () {
return {
options: []
}
},
computed: {
localValue: {
get () {
return this.value
},
set (newValue) {
this.$emit('input', newValue)
}
}
},
watch: {
value: throttle(200, async function (newValue) {
if (this.autocompleteList && newValue && newValue.length > this.autocompleteThreshold) {
this.options = await this.autocompleteList(newValue)
}
})
},
mounted () {
this.$nextTick(() => {
if (this.autofocus && document.activeElement !== this.$el) {
this.$refs.inputEl.focus()
}
})
}
}
</script>
<style lang="scss">
@import '../../styles/variables';
.input {
align-items: stretch;
width: 100%;
}
.input input {
display: block;
border: none;
background: rgba(255, 255, 255, .95);
color: $color-base;
padding: .75em 1em;
width: 100%;
flex: 1;
border-radius: 2px;
&::placeholder {
color: lighten($color-base, 45%);
}
+ .button {
margin-left: .5rem;
}
&[type="search"] {
border-radius: 500px;
padding-left: 1.25em;
padding-right: 1.25em;
}
}
</style>
......@@ -17,7 +17,7 @@
const markdownContent = findTextContent(this.$slots.default[0])
return h(
'div',
{ domProps: { innerHTML: nmd(markdownContent) } }
{ class: 'content', domProps: { innerHTML: nmd(markdownContent) } }
)
}
}
......
<template>
<div class="paginator" :class="`is-${behavior}`" v-if="!isEmpty || !hideEmpty">
<slot name="header" />
<slot name="header" :items="items" :count="count" :isLoading="isLoading"/>
<div class="paginator-items" ref="items" v-bind="$attrs"
:style="isLoading && currentItemsHeight ? { height: `${currentItemsHeight}px` } : null">
......@@ -13,7 +13,7 @@
</slot>
</div>
<slot name="footer"/>
<slot name="footer" :items="items" :count="count" :isLoading="isLoading"/>
</div>
</template>
......@@ -95,21 +95,26 @@
prevUrl: null,
isLoading: false,
currentItemsHeight: null,
abortController: null,
page: 1
}
},
methods: {
updateFrom (query, forceReplace = false) {
const abortController = new AbortController()
if (this.abortController) {
this.abortController.abort()
}
this.abortController = abortController
this.currentItemsHeight = this.$refs.items
? this.$refs.items.getBoundingClientRect().height
: this.minHeight || null
this.isLoading = true
return this.collectionQuery(query)
return this.collectionQuery(query, { signal: abortController.signal })
.then(data => {
this.items = forceReplace || this.behavior === 'replace'
? data.results
: [].concat(this.items, data.results)
this.items = data.results
this.count = data.count
this.nextUrl = getQueryString(data.next)
this.prevUrl = getQueryString(data.previous)
......@@ -122,6 +127,7 @@
.then(this.savePageInUrl)
.finally(() => {
this.isLoading = false
this.abortController = null
})
},
savePageInUrl () {
......
<template>
<app-field>
<form class="search" @submit.prevent>
<input type="text" v-model="searchTerm" class="search-input" :placeholder="$t('search_placeholder')"
:aria-label="$t('search_label')"/>
<app-button type="submit">{{ $t('search_submit') }}</app-button>
<form class="search" @submit.prevent="doSearch" :class="standalone ? 'is-standalone' : null">
<app-input type="search" v-model="searchData.keywords" name="keywords" :autofocus="!standalone"
:placeholder="$t('search.keyword_placeholder')" :label="$t('search.keyword_label')"
autocomplete="lohro_search">
<app-button type="submit" v-if="standalone">{{ $t('search.submit') }}</app-button>
</app-input>
</form>
</app-field>
</template>
......@@ -11,45 +13,74 @@
<script>
export default {
props: {
searchTerm: String
}
standalone: Boolean
},
data () {
const query = this.$route.query
return {
searchData: {
keywords: query.keywords || ''
}
}
},
methods: {
doSearch () {
const target = { name: 'search', query: this.searchData }
if (this.$route.name !== 'search') {
this.$router.push(target)
} else {
this.$router.replace(target)
}
}
},
watch: {
searchData: {
deep: true,
// as we fill pre-fill local state from the route we need
// to inform the receiver immediately
immediate: true,
handler (newSearchData) {
// we make a copy, because otherwise the receiver will
// get our local state and we practically invalidate
// any debounce or throttle mechanisms
this.$emit('search', { ...newSearchData })
}
}
},
inject: ['dataSources']
}
</script>
<style lang="scss">
.search {
display: flex;
}
flex: 1 0 auto;
margin: auto;
.search-input {
display: block;
border: none;
background: rgba(0, 0, 0, 0.25);
color: white;
border-radius: 500px 0 0 500px;
padding: .75em 1em;
width: 100%;
flex: 1;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, .39);
&:not(.is-standalone) .input input[type="search"] {
@media (min-width: 500px) {
min-width: 250px;
}
&::placeholder {
color: rgba(255, 255, 255, .25);
@media (min-width: 1200px) {
min-width: 400px;
}
}
}
.search .button {
border-radius: 0 500px 500px 0;
}
</style>
<i18n lang="yaml">
en:
search_label: Search for
search_submit: Search
search_placeholder: e.g. Communism
search.keyword_label: Search for
search.keyword_placeholder: Keywords, e.g. Communism
search.tag_label: Tags
search.tag_placeholder: e.g. me-too
search.submit: Search
de:
search_label: Suche nach
search_submit: Suchen
search_placeholder: z.B. Frauenkampftag
search.keyword_label: Suche nach
search.keyword_placeholder: Suchbegriff, z.B. Frauenkampftag
search.tag_label: Schlagworte
search.tag_placeholder: Schlagwort, z.B. me-too
search.submit: Suchen
</i18n>
......@@ -12,7 +12,7 @@
}
},
async created () {
if (this.env.live.trackService) {
if (this.env.live && this.env.live.trackService) {
const { url, ...options } = this.env.live.trackService
this.trackService = await createTrackServiceObservable(url, options)
}
......
......@@ -22,6 +22,7 @@ import Spinner from './generic/Spinner'
import SpinnerArea from './generic/SpinnerArea'
import Tag from './generic/Tag'
import Time from './generic/Time'
import Input from './generic/Input'
Vue.component('app-auto-grid', AutoGrid)
Vue.component('app-button', Button)
......@@ -32,6 +33,7 @@ Vue.component('app-download', Download)
Vue.component('app-field', Field)
Vue.component('app-header', Header)
Vue.component('app-image', Image)
Vue.component('app-input', Input)
Vue.component('app-like', Like)
Vue.component('app-markdown', Markdown)
Vue.component('app-page', Page)
......
......@@ -15,6 +15,7 @@ const createCache = (ttl) => {
const broadcastCache = createCache(300)
const recordingCache = createCache(120)
const seriesCache = createCache(600)
const tagCache = createCache(300)
export default function createDataSources (env) {
const createUrl = subPath => {
......@@ -29,15 +30,19 @@ export default function createDataSources (env) {
const broadcastBase = createUrl(`/api/v1/broadcasts`)
const queryBroadcast = id => broadcastCache(`${broadcastBase}/${id}`)
const queryBroadcastCollection = filterQuery => broadcastCache(`${broadcastBase}?${filterQuery}`)
const queryBroadcastCollection = (filterQuery, opts) => broadcastCache(`${broadcastBase}?${filterQuery}`, opts)
const recordingBase = createUrl(`/api/v1/recordings`)
const queryRecording = id => recordingCache(`${recordingBase}/${id}`)
const queryRecordingCollection = filterQuery => recordingCache(`${recordingBase}?${filterQuery}`)
const queryRecordingCollection = (filterQuery, opts) => recordingCache(`${recordingBase}?${filterQuery}`, opts)
const seriesBase = createUrl(`/api/v1/series`)
const querySeries = id => seriesCache(`${seriesBase}/${id}`)
const querySeriesCollection = filterQuery => seriesCache(`${seriesBase}?${filterQuery}`)
const querySeriesCollection = (filterQuery, opts) => seriesCache(`${seriesBase}?${filterQuery}`, opts)
const tagBase = createUrl(`/api/v1/tags`)
const queryTag = slug => tagCache(`${tagBase}/${slug}`)
const queryTagCollection = (filterQuery, opts) => tagCache(`${tagBase}?${filterQuery}`, opts)
return {
createImageUrl,
......@@ -46,6 +51,8 @@ export default function createDataSources (env) {
queryRecording,
queryRecordingCollection,
querySeries,
querySeriesCollection
querySeriesCollection,
queryTag,
queryTagCollection
}
}
import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'
import Vue from 'vue'
import VueMq from 'vue-mq'
import VueWindowSize from 'vue-window-size'
......
......@@ -25,7 +25,8 @@
}
.content {
@extend .hyphens;
@extend .hyphens, .f-4;
line-height: 1.4;
}
.align-center {
......
......@@ -3,7 +3,7 @@
<app-container class="my-5">
<app-section>
<app-header :title="$t('about_page.title', [env.name])" class="mb-4"/>
<app-markdown class="f-4" v-if="env.about">{{ env.about }}</app-markdown>
<app-markdown v-if="env.about">{{ env.about }}</app-markdown>
</app-section>
<app-section v-if="socialPlatforms.length > 0">
<app-header :title="$t('about_page.social')" :level="2" class="mb-4"/>
......
......@@ -2,7 +2,7 @@
<app-page>
<app-container class="my-5">
<app-header :title="env.name" class="mb-5">
<PlayLive look="primary" :size="$mq !== 'xs' ? 'medium' : null"/>
<PlayLive look="primary" :size="$mq !== 'xs' ? 'medium' : null" v-if="env.live"/>
</app-header>
<app-section>
<app-auto-grid :sizes="{ md: [2, 1], lg: [2, 1], xl: [2, 1] }" :gap-modifier="2">
......@@ -28,7 +28,7 @@
<div>
<app-section class="mb-4">
<app-header :title="$t('home_page_search')" :level="2" class="mb-3"/>
<app-search/>
<app-search standalone/>
</app-section>
<app-section>
<app-header :title="$t('home_page_recent_topics')" :level="2" class="mb-3"/>
......
......@@ -15,7 +15,7 @@
<app-auto-grid :sizes="{ md: [2, 1], lg: [2, 1], xl: [2, 1] }" :gap-modifier="2">
<div>
<app-header :title="$t('recording_page.info')" :level="2" :display-level="4" class="mb-3"/>
<div class="content f-4 mb-2">
<div class="mb-2">
<app-markdown v-if="recording.description">{{ recording.description }}</app-markdown>
<app-markdown v-else>
<i18n path="recording_page.missing_description">
......
......@@ -2,20 +2,76 @@
<app-page>
<app-container class="my-5">
<app-section>
<app-header :title="$t('search_page_title')"/>
<app-header :title="$t('search_page.title')" always-show-title class="mb-5">
<app-search @search="updateSearchData"/>
</app-header>
<app-paginator :collection-query="dataSources.queryRecordingCollection" behavior="append"
no-loader :min-height="0" :page-size="recordingCollection.count"
:filters="recordingFilters" id="recordings">
<template v-slot:default="{ items, isLoading }">
<app-auto-grid :default-size="recordingCollection.columns" v-if="items.length > 0">
<div v-for="recording in items" :key="recording.id">
<RecordingPreview :recording="recording" class="mb-3"/>
</div>
</app-auto-grid>
<div class="content center" style="max-width: 600px; margin: 0 auto;"
v-else-if="items.length === 0 && !isLoading">
<p>{{ $t('search_page.no_results') }}</p>
</div>
</template>
<template v-slot:footer="{ isLoading }">
<div style="height: 100px; position: relative">
<app-spinner-area v-if="isLoading"/>
<app-paginator-nav class="mt-3"/>
</div>
</template>
</app-paginator>
</app-section>
</app-container>
</app-page>
</template>
<script>
export default {}
import { debounce } from 'throttle-debounce'
import RecordingPreview from '../components/model/RecordingPreview'
export default {
components: { RecordingPreview },
data () {
return {
searchData: {}
}
},
computed: {
recordingFilters () {
return {
...this.searchData,
is_available: 1
}
},
recordingCollection () {
return {
xs: { count: 10, columns: 1 },
md: { count: 30, columns: 3 }
}[this.$mq] || { count: 40, columns: 4 }
}
},
methods: {
updateSearchData: debounce(350, false, function (newData) {
this.searchData = newData
})
},
inject: ['dataSources']
}
</script>
<i18n lang="yaml">
en:
search_page_title: Search
search_page.title: Search
search_page.no_results: 'We couldn’t find any posts for your query. Sorry!'
de:
search_page_title: Suche
search_page.title: Suche
search_page.no_results: 'Wir konnten keine Beiträge für deine Suche finden. Tschuldigung!'
</i18n>