Skip to content
Commits on Source (5)
<template>
<div class="grid" :style="style">
<component :is="tag || 'div'" class="grid ma-0 pa-0" :style="style">
<slot />
</div>
</component>
</template>
<script>
......@@ -15,7 +15,8 @@
defaultSize: {
type: Number,
default: 1
}
},
tag: String
},
computed: {
style () {
......@@ -33,8 +34,12 @@
}
</script>
<style>
<style lang="scss">
.grid {
display: grid;
> li {
display: block;
}
}
</style>
......@@ -175,16 +175,16 @@
.image.is-covering-screen {
--luminosity: .5;
width: calc(100% + 2rem) !important;
width: 100% !important;
height: 75vh !important;
overflow: hidden;
pointer-events: none;
filter: blur(10px);
opacity: .3;
position: absolute;
top: -1rem;
left: -1rem;
right: -1rem;
top: 0;
left: 0;
right: 0;
img {
max-height: none !important;
......
<template>
<div class="paginator-indicator mt-4" v-if="shouldDisplay">
<div class="paginator-indicator-bubbles" v-if="paginator.numberOfPages <= 10">
<span class="paginator-indicator-bubble" :class="{ 'is-active': paginator.page === page }"
v-for="page in pages" :key="page"></span>
</div>
<span class="opaque-7" v-else>{{ paginator.page }} / {{ paginator.numberOfPages }}</span>
</div>
</template>
<script>
export default {
computed: {
pages () {
return new Array(this.paginator.numberOfPages).fill(0).map((_, index) => index + 1)
},
shouldDisplay () {
console.log(this.paginator.collection, this.paginator.useTouch, this.paginator.behavior === 'replace', this.paginator.numberOfPages)
return this.paginator.useTouch && this.paginator.behavior === 'replace' && this.paginator.numberOfPages > 1
}
},
inject: ['paginator']
}
</script>
<style lang="scss">
@import '../../styles/variables';
$size: .95rem;
$inactive-color: $color-surface;
$active-color: lighten($color-surface, 30%);
.paginator-indicator {
text-align: center;
}
.paginator-indicator-bubbles {
display: grid;
grid-template-columns: repeat(auto-fit, $size);
grid-gap: ($size * .65);
justify-content: center;
}
.paginator-indicator-bubble {
background: $inactive-color;
width: $size;
height: $size;
border-radius: 50%;
position: relative;
border: ($size / 4.5) solid $inactive-color;
&.is-active {
background: $active-color;
}
}
</style>
......@@ -3,14 +3,19 @@
<slot name="header" :items="items" :count="count" :isLoading="isLoading" :hasNext="hasNext" :hasPrev="hasPrev"/>
<div class="paginator-items" ref="items" v-bind="$attrs"
:style="isLoading && currentItemsHeight ? { height: `${currentItemsHeight}px` } : null">
:style="isLoading && currentItemsHeight ? { height: `${currentItemsHeight}px` } : null"
@touchstart.passive="$emit('touchnav-start', $event)"
@touchmove.passive="$emit('touchnav-move', $event)"
@touchend.passive="$emit('touchnav-end', $event)">
<slot :items="items" :count="count" :isLoading="isLoading" :hasNext="hasNext" :hasPrev="hasPrev"></slot>
<app-spinner-area v-if="!noLoader && isLoading"/>
<app-spinner-area v-if="!noLoader && isLoading && !useTouch"/>
<PaginatorTouchNav ref="touchNav" v-if="useTouch"/>
<slot name="empty-items" v-if="emptyMessage">
<div class="content" v-if="isEmpty && !isLoading">
<p>{{ emptyMessage }}</p>
</div>
</slot>
<PageIndicator/>
</div>
<slot name="footer" :items="items" :count="count" :isLoading="isLoading" :hasNext="hasNext" :hasPrev="hasPrev"/>
......@@ -21,10 +26,15 @@
import { pullRefreshController } from '../../util/dom'
import { createQueryString } from '../../util'
import { decodeHash, manipulateHash } from '../../util/encoder'
import PaginatorTouchNav from './PaginatorTouchNav'
import PageIndicator from './PageIndicator'
const { matchMedia } = window
const getQueryString = url => url ? new URL(url).search.substring(1) : null
export default {
components: { PageIndicator, PaginatorTouchNav },
props: {
collection: {
type: Object,
......@@ -67,6 +77,20 @@
},
noLoader: Boolean
},
data () {
return {
items: [],
count: null,
nextUrl: null,
prevUrl: null,
isLoading: false,
currentItemsHeight: null,
abortController: null,
page: 1,
currentRequest: null,
useTouch: matchMedia('(pointer: coarse)').matches
}
},
computed: {
hasNext () {
if (this.nextUrl === null) return false
......@@ -86,22 +110,13 @@
},
isEmpty () {
return this.count === 0
}
},
data () {
return {
items: [],
count: null,
nextUrl: null,
prevUrl: null,
isLoading: false,
currentItemsHeight: null,
abortController: null,
page: 1
},
numberOfPages () {
return Math.min(this.pageLimit || Number.POSITIVE_INFINITY, Math.ceil(this.count / this.pageSize))
}
},
methods: {
updateFrom (query, forceReplace = false) {
updateFrom (query, { forceReplace = false, wait = Promise.resolve() }) {
const abortController = new AbortController()
if (this.abortController) {
this.abortController.abort()
......@@ -111,7 +126,11 @@
? this.$refs.items.getBoundingClientRect().height
: this.minHeight || null
this.isLoading = true
return this.collection.list(query, { signal: abortController.signal })
this.currentRequest = this.collection.list(query, { signal: abortController.signal })
.then(async data => {
await wait
return data
})
.then(data => {
this.items = forceReplace || this.behavior === 'replace'
? data.results
......@@ -129,7 +148,9 @@
.finally(() => {
this.isLoading = false
this.abortController = null
this.currentRequest = null
})
return this.currentRequest
},
savePageInUrl () {
if (this.id) {
......@@ -145,22 +166,22 @@
this.items = []
this.count = this.nextUrl = this.prevUrl = null
this.page = typeof page === 'number' ? Math.max(1, page) : 1
return this.updateFrom(this.currentQueryString, true)
return this.updateFrom(this.currentQueryString, { forceReplace: true })
},
refresh () {
this.collection.clearCache()
return this.reset()
},
prev () {
prev (options) {
if (this.hasPrev) {
this.page -= 1
return this.updateFrom(this.currentQueryString)
return this.updateFrom(this.currentQueryString, options)
}
},
next () {
next (options) {
if (this.hasNext) {
this.page += 1
return this.updateFrom(this.currentQueryString)
return this.updateFrom(this.currentQueryString, options)
}
}
},
......@@ -189,5 +210,7 @@
<style>
.paginator-items {
position: relative;
overflow: hidden;
overscroll-behavior-x: contain;
}
</style>
......@@ -3,7 +3,7 @@
<template v-if="paginator.behavior === 'append'">
<app-button @click="paginator.next" v-if="paginator.hasNext">{{ $t('paginator_more') }}</app-button>
</template>
<template v-else>
<template v-else-if="!paginator.useTouch">
<app-button @click="paginator.prev" :disabled="!paginator.hasPrev" class="mr-3"
:aria-label="$t('paginator_backward_label')" :size="isSmall ? 'small' : null"
:style="{ visibility: paginator.hasPrev ? null : 'hidden' }">
......
<template>
<div class="paginator-touch-loader" :style="style" @animationend="onAnimationEnd">
<app-spinner-area v-if="paginator.isLoading"/>
</div>
</template>
<script>
import { deferred } from '../../util'
const ANIMATION_TIME = 0.35
export default {
data () {
return {
start: null,
offset: null,
snap: null,
isSnapping: null
}
},
computed: {
elWidth () {
const width = this.$el.getBoundingClientRect().width
return this.windowWidth ? width : width
},
style () {
const { offset, snap, isSnapping } = this
const { hasPrev, hasNext } = this.paginator
// if we have no offset nobody feels touchy
if (offset === null) { return null }
// if the swipe direction is forward, but there is no next page we don’t do anything
// same goes for backward and previous pages
const isForward = offset > 0
if ((isForward && !hasNext) || (!isForward && !hasPrev)) { return null }
const [left, right] = isForward ? ['100%', 'auto'] : ['auto', '100%']
const opacity = Math.min(1, 0.35 + Math.abs(offset) / this.elWidth)
const animation = isSnapping ? 'touch-loader-snap' : 'touch-loader-end'
return [
{ left, right },
snap
? {
animation: `${animation} ${ANIMATION_TIME}s ease-out forwards`,
'--opacity-start': opacity,
'--opacity-end': isForward ? offset < snap : offset > snap ? 0 : 1,
'--snap-start': `${-offset}px`,
'--snap-end': `${snap}px`
}
: {
transition: 'transform 0.05s, opacity .05s',
transform: `translateX(${-offset}px)`,
opacity
}
]
}
},
methods: {
setStart (event) {
if (!this.paginator.currentRequest) {
this.resetTouchNav()
this.start = event.touches[0].clientX
}
},
setOffset (event) {
if (this.start) {
const offset = this.start - event.touches[0].clientX
// Protection against over-scroll.
// We want the overlay to stay in place over the paginator.
this.offset = offset < 0
? Math.max(-this.elWidth, offset)
: Math.min(this.elWidth, offset)
}
},
triggerNav () {
const { elWidth, offset } = this
if (offset !== null) {
const isForward = offset > 0
const snap = isForward ? -elWidth : elWidth
this.isSnapping = deferred()
this.isSnapping.then(() => { this.isSnapping = null })
// safe-guard against missing animationend events
setTimeout(() => { this.isSnapping && this.isSnapping.resolve() }, ANIMATION_TIME * 1.05 * 1000)
if (Math.abs(offset) > elWidth / 3) {
this.snap = snap
this.paginator[isForward ? 'next' : 'prev']({ wait: this.isSnapping })
} else {
this.snap = -snap
}
}
},
async onAnimationEnd (event) {
if (event.animationName === 'touch-loader-snap') {
this.isSnapping.resolve()
await (this.paginator.currentRequest || Promise.resolve())
} else {
this.resetTouchNav()
}
},
resetTouchNav () {
this.offset = null
this.start = null
this.snap = null
this.isSnapping = null
}
},
inject: ['paginator'],
mounted () {
this.paginator.$on('touchnav-start', this.setStart)
this.paginator.$on('touchnav-move', this.setOffset)
this.paginator.$on('touchnav-end', this.triggerNav)
},
beforeDestroy () {
this.paginator.$off('touchnav-start', this.setStart)
this.paginator.$off('touchnav-move', this.setOffset)
this.paginator.$off('touchnav-end', this.triggerNav)
}
}
</script>
<style lang="scss">
@import '../../styles/variables';
.paginator-touch-loader {
position: absolute;
background: $color-base;
top: 0;
bottom: 0;
width: 100%;
opacity: 0;
pointer-events: none;
}
@keyframes touch-loader-snap {
from {
transform: translateX(var(--snap-start));
opacity: var(--opacity-start)
}
100% {
transform: translateX(var(--snap-end));
opacity: var(--opacity-end)
}
}
@keyframes touch-loader-end {
from {
transform: translateX(var(--snap-end));
opacity: var(--opacity-end)
}
25% {
transform: translateX(var(--snap-end));
opacity: var(--opacity-end)
}
to {
transform: translateX(var(--snap-end));
opacity: 0
}
}
</style>
......@@ -4,6 +4,15 @@ export const renderSimpleTemplateString = (str, ctx) => {
return str.replace(/\{([a-z_]+)\}/gi, (match, key) => ctx[key] || '')
}
export const delay = (time, data) => new Promise(resolve => { setTimeout(() => { resolve(data) }, time) })
export const deferred = () => {
let resolver
const promise = new Promise(resolve => { resolver = resolve })
promise.resolve = value => resolver(value)
return promise
}
export const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
export const createQueryString = queryArgs => new URLSearchParams(queryArgs).toString()
......
......@@ -17,10 +17,10 @@
</template>
<template v-slot:default="{ items }">
<app-auto-grid :sizes="{ md: 2, lg: 3, xl: 3 }">
<div v-for="recording in items" :key="recording.id">
<RecordingPreview :recording="recording" class="mb-3"/>
</div>
<app-auto-grid :sizes="{ md: 2, lg: 3, xl: 3 }" tag="ol">
<li v-for="recording in items" :key="recording.id">
<RecordingPreview :recording="recording"/>
</li>
</app-auto-grid>
</template>
</app-paginator>
......@@ -56,10 +56,10 @@
</template>
<template v-slot:default="{ items }">
<app-auto-grid :sizes="{ xs: 2, sm: 3, md: 4, lg: 5, xl: 6 }">
<div v-for="series in items" :key="series.id">
<app-auto-grid :sizes="{ xs: 2, sm: 3, md: 4, lg: 5, xl: 6 }" tag="ol">
<li v-for="series in items" :key="series.id">
<SeriesPreview :series="series"/>
</div>
</li>
</app-auto-grid>
</template>
</app-paginator>
......
......@@ -42,10 +42,10 @@
</template>
<template v-slot:default="{ items }">
<app-auto-grid>
<div v-for="recording in items" :key="recording.id">
<app-auto-grid tag="ol">
<li v-for="recording in items" :key="recording.id">
<RecordingPreview :recording="recording" class="mb-3"/>
</div>
</li>
</app-auto-grid>
</template>
</app-paginator>
......
......@@ -9,10 +9,10 @@
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 :default-size="recordingCollection.columns" v-if="items.length > 0" tag="ol">
<li v-for="recording in items" :key="recording.id">
<RecordingPreview :recording="recording"/>
</li>
</app-auto-grid>
<div class="content center" style="max-width: 600px; margin: 0 auto;"
v-else-if="items.length === 0 && !isLoading">
......@@ -20,10 +20,10 @@
</div>
</template>
<template v-slot:footer="{ isLoading }">
<div style="height: 100px; position: relative">
<template v-slot:footer="{ isLoading, hasNext }">
<div style="height: 100px; position: relative" v-if="hasNext">
<app-spinner-area v-if="isLoading"/>
<app-paginator-nav class="mt-3"/>
<app-paginator-nav class="mt-5"/>
</div>
</template>
</app-paginator>
......
......@@ -26,10 +26,10 @@
</template>
<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 :default-size="recordingCollection.columns" v-if="items.length > 0" tag="ol">
<li v-for="recording in items" :key="recording.id">
<RecordingPreview :recording="recording"/>
</li>
</app-auto-grid>
<div class="content" v-else-if="items.length === 0 && !isLoading">
<p>{{ $t('series_page.no_recordings') }}</p>
......@@ -53,10 +53,10 @@
</template>
<template v-slot:default="{ items }">
<app-auto-grid :default-size="1">
<div v-for="broadcast in items" :key="broadcast.id">
<app-auto-grid :default-size="1" tag="ol">
<li v-for="broadcast in items" :key="broadcast.id">
<BroadcastPreview :broadcast="broadcast" :series="series"/>
</div>
</li>
</app-auto-grid>
</template>
</app-paginator>
......