This commit is contained in:
Mark Cooper
2021-09-01 20:07:11 -05:00
commit 0fef23e47b
106 changed files with 18798 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
<template>
<div ref="wrapper" class="w-full pt-2">
<div class="relative">
<div class="flex mt-2 mb-4">
<div class="flex-grow" />
<template v-if="!loading">
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
<span class="material-icons text-3xl">first_page</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="backward10">
<span class="material-icons text-3xl">replay_10</span>
</div>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<span class="material-icons">{{ seekLoading ? 'autorenew' : isPaused ? 'play_arrow' : 'pause' }}</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="forward10">
<span class="material-icons text-3xl">forward_10</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300 ml-8" @mousedown.prevent @mouseup.prevent>
<span class="font-mono text-lg uppercase">1x</span>
</div>
</template>
<template v-else>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
<span class="material-icons">autorenew</span>
</div>
</template>
<div class="flex-grow" />
</div>
<!-- Track -->
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125" :class="loading ? 'animate-pulse' : ''" @click.stop="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferTrack" class="h-full bg-gray-400 absolute top-0 left-0 pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
</div>
<div class="flex items-center py-1 px-0.5">
<div>
<p ref="currentTimestamp" class="font-mono text-sm">00:00:00</p>
</div>
<div class="flex-grow" />
<div>
<p class="font-mono text-sm">{{ totalDurationPretty }}</p>
</div>
</div>
</div>
<!-- <audio ref="audio" @progress="progress" @pause="paused" @playing="playing" @timeupdate="timeupdate" @loadeddata="audioLoadedData" /> -->
</div>
</template>
<script>
import MyNativeAudio from '@/plugins/my-native-audio'
export default {
props: {
loading: Boolean
},
data() {
return {
totalDuration: 0,
currentTime: 0,
isTerminated: false,
initObject: null,
stateName: 'idle',
playInterval: null,
trackWidth: 0,
isPaused: true,
src: null,
volume: 0.5,
readyTrackWidth: 0,
bufferTrackWidth: 0,
playedTrackWidth: 0,
seekedTime: 0,
seekLoading: false
}
},
computed: {
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
}
},
methods: {
restart() {
this.seek(0)
},
backward10() {
MyNativeAudio.seekBackward10()
},
forward10() {
MyNativeAudio.seekForward10()
},
sendStreamUpdate() {
this.$emit('updateTime', this.currentTime)
},
setStreamReady() {
this.readyTrackWidth = this.trackWidth
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
},
setChunksReady(chunks, numSegments) {
var largestSeg = 0
for (let i = 0; i < chunks.length; i++) {
var chunk = chunks[i]
if (typeof chunk === 'string') {
var chunkRange = chunk.split('-').map((c) => Number(c))
if (chunkRange.length < 2) continue
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
} else if (chunk > largestSeg) {
largestSeg = chunk
}
}
var percentageReady = largestSeg / numSegments
var widthReady = Math.round(this.trackWidth * percentageReady)
if (this.readyTrackWidth === widthReady) {
return
}
this.readyTrackWidth = widthReady
this.$refs.readyTrack.style.width = widthReady + 'px'
},
updateTimestamp() {
var ts = this.$refs.currentTimestamp
if (!ts) {
console.error('No timestamp el')
return
}
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
ts.innerText = currTimeClean
},
timeupdate() {
if (!this.$refs.playedTrack) {
console.error('Invalid no played track ref')
return
}
if (this.seekLoading) {
this.seekLoading = false
if (this.$refs.playedTrack) {
this.$refs.playedTrack.classList.remove('bg-yellow-300')
this.$refs.playedTrack.classList.add('bg-gray-200')
}
}
this.updateTimestamp()
this.sendStreamUpdate()
var perc = this.currentTime / this.totalDuration
var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
}
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
},
seek(time) {
if (this.seekLoading) {
console.error('Already seek loading', this.seekedTime)
return
}
this.seekedTime = time
this.seekLoading = true
MyNativeAudio.seekPlayer({ timeMs: String(Math.floor(time * 1000)) })
if (this.$refs.playedTrack) {
var perc = time / this.totalDuration
var ptWidth = Math.round(perc * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
this.$refs.playedTrack.classList.remove('bg-gray-200')
this.$refs.playedTrack.classList.add('bg-yellow-300')
}
},
updateVolume(volume) {},
clickTrack(e) {
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
var time = perc * this.totalDuration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
}
this.seek(time)
},
playPauseClick() {
if (this.isPaused) {
console.log('playPause PLAY')
this.play()
} else {
console.log('playPause PAUSE')
this.pause()
}
},
set(audiobookStreamData) {
this.initObject = { ...audiobookStreamData }
MyNativeAudio.initPlayer(this.initObject)
},
setFromObj() {
if (!this.initObject) {
console.error('Cannot set from obj')
return
}
MyNativeAudio.initPlayer(this.initObject)
},
play() {
MyNativeAudio.playPlayer()
this.startPlayInterval()
},
pause() {
MyNativeAudio.pausePlayer()
this.stopPlayInterval()
},
startPlayInterval() {
clearInterval(this.playInterval)
this.playInterval = setInterval(async () => {
var data = await MyNativeAudio.getCurrentTime()
this.currentTime = Number((data.value / 1000).toFixed(2))
this.timeupdate()
}, 1000)
},
stopPlayInterval() {
clearInterval(this.playInterval)
},
terminateStream(startTime) {
var _time = String(Math.floor(startTime * 1000))
if (!this.initObject) {
console.error('Terminate stream when no init object is set...')
return
}
this.initObject.currentTime = _time
MyNativeAudio.terminateStream()
},
init() {
MyNativeAudio.addListener('onPlayingUpdate', (data) => {
this.isPaused = !data.value
if (!this.isPaused) {
this.startPlayInterval()
} else {
this.stopPlayInterval()
}
})
MyNativeAudio.addListener('onMetadata', (data) => {
console.log('Native Audio On Metadata', JSON.stringify(data))
this.totalDuration = Number((data.duration / 1000).toFixed(2))
this.currentTime = Number((data.currentTime / 1000).toFixed(2))
this.stateName = data.stateName
if (this.stateName === 'ended' && this.isTerminated) {
this.setFromObj()
}
this.timeupdate()
})
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
}
},
mounted() {
this.$nextTick(this.init)
},
beforeDestroy() {
clearInterval(this.playInterval)
}
}
</script>
+74
View File
@@ -0,0 +1,74 @@
<template>
<div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 left-0 w-full h-full z-30 flex items-center px-2">
<nuxt-link v-show="!showBack" to="/" class="mr-4">
<img src="/Logo.png" class="h-12 w-12" />
</nuxt-link>
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-2 cursor-pointer">
<span class="material-icons text-4xl text-white">arrow_back</span>
</a>
<p class="text-xl font-book">AudioBookshelf</p>
<div class="flex-grow" />
<ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" />
</div>
</div>
</template>
<script>
export default {
data() {
return {
menuItems: [
{
value: 'account',
text: 'Account',
to: '/account'
},
{
value: 'logout',
text: 'Logout'
}
]
}
},
computed: {
showBack() {
return this.$route.name !== 'index'
},
user() {
return this.$store.state.user.user
},
username() {
return this.user ? this.user.username : 'err'
}
},
methods: {
back() {
if (this.$route.name === 'audiobook-id-edit') {
this.$router.push(`/audiobook/${this.$route.params.id}`)
} else {
this.$router.push('/')
}
},
logout() {
this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
this.$server.logout()
this.$router.push('/connect')
},
menuAction(action) {
if (action === 'logout') {
this.logout()
}
}
},
mounted() {}
}
</script>
<style>
#appbar {
box-shadow: 0px 5px 5px #11111155;
}
</style>
+116
View File
@@ -0,0 +1,116 @@
<template>
<div id="bookshelf" ref="wrapper" class="w-full overflow-y-auto">
<template v-for="(shelf, index) in groupedBooks">
<div :key="index" class="border-b border-opacity-10 w-full bookshelfRow py-4 flex justify-around relative">
<template v-for="audiobook in shelf">
<div :key="audiobook.id" class="relative px-4">
<nuxt-link :to="`/audiobook/${audiobook.id}`">
<cards-book-cover :audiobook="audiobook" :width="cardWidth" class="mx-auto -mb-px" />
</nuxt-link>
</div>
</template>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
</div>
</template>
<div class="w-full py-16 text-center text-xl">
<div class="py-4">No Audiobooks</div>
<ui-btn v-if="hasFilters" @click="clearFilter">Clear Filter</ui-btn>
</div>
</div>
</template>
<script>
export default {
data() {
return {
currFilterOrderKey: null,
groupedBooks: [],
pageWidth: 0
}
},
computed: {
cardWidth() {
return 140
},
cardHeight() {
return this.cardWidth * 2
},
filterOrderKey() {
return this.$store.getters['user/getFilterOrderKey']
},
hasFilters() {
return this.$store.getters['user/getUserSetting']('filterBy') !== 'all'
}
},
methods: {
clearFilter() {
this.$store.dispatch('user/updateUserSettings', {
filterBy: 'all'
})
},
playAudiobook(audiobook) {
console.log('Play Audiobook', audiobook)
this.$store.commit('setStreamAudiobook', audiobook)
this.$server.socket.emit('open_stream', audiobook.id)
},
calcShelves() {
var booksPerShelf = Math.floor(this.pageWidth / (this.cardWidth + 32))
var groupedBooks = []
var audiobooksSorted = this.$store.getters['audiobooks/getFilteredAndSorted']()
this.currFilterOrderKey = this.filterOrderKey
var numGroups = Math.ceil(audiobooksSorted.length / booksPerShelf)
for (let i = 0; i < numGroups; i++) {
var group = audiobooksSorted.slice(i * booksPerShelf, i * booksPerShelf + 2)
groupedBooks.push(group)
}
this.groupedBooks = groupedBooks
},
audiobooksUpdated() {
this.calcShelves()
},
init() {
if (this.$refs.wrapper) {
this.pageWidth = this.$refs.wrapper.clientWidth
this.calcShelves()
}
},
resize() {
this.init()
},
settingsUpdated() {
if (this.currFilterOrderKey !== this.filterOrderKey) {
this.calcShelves()
}
}
},
mounted() {
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
window.addEventListener('resize', this.resize)
this.$store.dispatch('audiobooks/load')
this.init()
},
beforeDestroy() {
this.$store.commit('audiobooks/removeListener', 'bookshelf')
this.$store.commit('user/removeSettingsListener', 'bookshelf')
window.removeEventListener('resize', this.resize)
}
}
</script>
<style>
#bookshelf {
height: calc(100% - 48px);
}
.bookshelfRow {
background-image: url(/wood_panels.jpg);
}
.bookshelfDivider {
background: rgb(149, 119, 90);
background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%);
box-shadow: 2px 14px 8px #111111aa;
}
</style>
+187
View File
@@ -0,0 +1,187 @@
<template>
<div class="w-full p-4 pointer-events-none fixed bottom-0 left-0 right-0 z-20">
<div v-if="streamAudiobook" class="w-full bg-primary absolute bottom-0 left-0 right-0 z-50 p-2 pointer-events-auto" @click.stop @mousedown.stop @mouseup.stop>
<div class="pl-16 pr-2 flex items-center pb-2">
<div>
<p class="px-2">{{ title }}</p>
<p class="px-2 text-xs text-gray-400">by {{ author }}</p>
</div>
<div class="flex-grow" />
<span class="material-icons" @click="cancelStream">close</span>
</div>
<div class="absolute left-2 -top-10">
<cards-book-cover :audiobook="streamAudiobook" :width="64" />
</div>
<audio-player-mini ref="audioPlayerMini" :loading="!stream || currStreamAudiobookId !== streamAudiobookId" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
</div>
</div>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
export default {
data() {
return {
audioPlayerReady: false,
stream: null,
lastServerUpdateSentSeconds: 0
}
},
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
},
streamAudiobookId() {
return this.streamAudiobook ? this.streamAudiobook.id : null
},
currStreamAudiobookId() {
return this.stream ? this.stream.audiobook.id : null
},
book() {
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
},
title() {
return this.book ? this.book.title : ''
},
author() {
return this.book ? this.book.author : ''
},
cover() {
return this.book ? this.book.cover : ''
},
series() {
return this.book ? this.book.series : ''
},
volumeNumber() {
return this.book ? this.book.volumeNumber : ''
},
seriesTxt() {
if (!this.series) return ''
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
duration() {
return this.streamAudiobook ? this.streamAudiobook.duration || 0 : 0
},
coverForNative() {
if (!this.cover) {
return `${this.$store.state.serverUrl}/Logo.png`
}
if (this.cover.startsWith('http')) return this.cover
var _clean = this.cover.replace(/\\/g, '/')
if (_clean.startsWith('/local')) {
var _cover = process.env.NODE_ENV !== 'production' && process.env.PROD !== '1' ? _clean.replace('/local', '') : _clean
return `${this.$store.state.serverUrl}${_cover}`
}
return _clean
}
},
methods: {
async cancelStream() {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Cancel this stream?'
})
if (value) {
this.$server.socket.emit('close_stream')
}
},
updateTime(currentTime) {
var diff = currentTime - this.lastServerUpdateSentSeconds
if (diff > 4 || diff < 0) {
this.lastServerUpdateSentSeconds = currentTime
var updatePayload = {
currentTime,
streamId: this.stream.id
}
this.$server.socket.emit('stream_update', updatePayload)
}
},
closeStream() {},
streamClosed(audiobookId) {
console.log('Stream Closed')
if (this.stream.audiobook.id === audiobookId || audiobookId === 'n/a') {
this.$store.commit('setStreamAudiobook', null)
}
},
streamProgress(data) {
if (!data.numSegments) return
var chunks = data.chunks
if (this.$refs.audioPlayerMini) {
this.$refs.audioPlayerMini.setChunksReady(chunks, data.numSegments)
}
},
streamReady() {
console.log('[StreamContainer] Stream Ready')
if (this.$refs.audioPlayerMini) {
this.$refs.audioPlayerMini.setStreamReady()
}
},
streamReset({ streamId, startTime }) {
if (this.$refs.audioPlayerMini) {
if (this.stream && this.stream.id === streamId) {
this.$refs.audioPlayerMini.terminateStream(startTime)
}
}
},
streamOpen(stream) {
console.log('[StreamContainer] Stream Open', stream)
if (!this.$refs.audioPlayerMini) {
console.error('No Audio Player Mini')
return
}
if (this.stream && this.stream.id !== stream.id) {
console.error('STREAM CHANGED', this.stream.id, stream.id)
}
this.stream = stream
var playlistUrl = stream.clientPlaylistUri
var currentTime = stream.clientCurrentTime || 0
var playOnLoad = this.$store.state.playOnLoad
if (playOnLoad) this.$store.commit('setPlayOnLoad', false)
var audiobookStreamData = {
title: this.title,
author: this.author,
playWhenReady: !!playOnLoad,
startTime: String(Math.floor(currentTime * 1000)),
cover: this.coverForNative,
duration: String(Math.floor(this.duration * 1000)),
series: this.seriesTxt,
playlistUrl: this.$server.url + playlistUrl,
token: this.$store.getters['user/getToken']
}
console.log('audiobook stream data', audiobookStreamData.token, JSON.stringify(audiobookStreamData))
this.$refs.audioPlayerMini.set(audiobookStreamData)
},
audioPlayerMounted() {
console.log('Audio Player Mounted', this.$server.stream)
this.audioPlayerReady = true
if (this.$server.stream) {
this.streamOpen(this.$server.stream)
}
},
setListeners() {
if (!this.$server.socket) {
console.error('Invalid server socket not set')
return
}
this.$server.socket.on('stream_open', this.streamOpen)
this.$server.socket.on('stream_closed', this.streamClosed)
this.$server.socket.on('stream_progress', this.streamProgress)
this.$server.socket.on('stream_ready', this.streamReady)
this.$server.socket.on('stream_reset', this.streamReset)
}
},
mounted() {
console.warn('Stream Container Mounted')
this.setListeners()
}
}
</script>
+144
View File
@@ -0,0 +1,144 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
<div class="w-full h-full relative">
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" />
</div>
<img ref="cover" :src="fullCoverUrl" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
</div>
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/LogoTransparent.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
</div>
</div>
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
},
authorOverride: String,
width: {
type: Number,
default: 120
}
},
data() {
return {
imageFailed: false,
showCoverBg: false
}
},
watch: {
cover() {
this.imageFailed = false
}
},
computed: {
book() {
return this.audiobook.book || {}
},
title() {
return this.book.title || 'No Title'
},
titleCleaned() {
if (this.title.length > 60) {
return this.title.slice(0, 57) + '...'
}
return this.title
},
author() {
if (this.authorOverride) return this.authorOverride
return this.book.author || 'Unknown'
},
authorCleaned() {
if (this.author.length > 30) {
return this.author.slice(0, 27) + '...'
}
return this.author
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
serverUrl() {
return this.$store.state.serverUrl
},
fullCoverUrl() {
if (this.cover.startsWith('http')) return this.cover
var _clean = this.cover.replace(/\\/g, '/')
if (_clean.startsWith('/local')) {
var _cover = process.env.NODE_ENV !== 'production' && process.env.PROD !== '1' ? _clean.replace('/local', '') : _clean
return `${this.serverUrl}${_cover}`
}
return _clean
},
cover() {
return this.book.cover || this.placeholderUrl
},
hasCover() {
return !!this.book.cover
},
sizeMultiplier() {
return this.width / 120
},
titleFontSize() {
return 0.75 * this.sizeMultiplier
},
authorFontSize() {
return 0.6 * this.sizeMultiplier
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
authorBottom() {
return 0.75 * this.sizeMultiplier
}
},
methods: {
setCoverBg() {
if (this.$refs.coverBg) {
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
this.$refs.coverBg.style.backgroundSize = 'cover'
this.$refs.coverBg.style.backgroundPosition = 'center'
this.$refs.coverBg.style.opacity = 0.25
this.$refs.coverBg.style.filter = 'blur(1px)'
}
},
hideCoverBg() {},
imageLoaded() {
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - 1.6)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
this.showCoverBg = true
this.$nextTick(this.setCoverBg)
} else {
this.showCoverBg = false
}
}
},
imageError(err) {
console.error('ImgError', err)
this.imageFailed = true
}
}
}
</script>
+165
View File
@@ -0,0 +1,165 @@
<template>
<modals-modal v-model="show" width="90%" height="100%">
<template #outer>
<div v-show="selected !== 'all'" class="absolute top-4 left-4 z-40">
<ui-btn class="text-xl border-yellow-400 border-opacity-40" @click="clearSelected">Clear</ui-btn>
<!-- <span class="font-semibold uppercase text-white text-2xl">Clear</span> -->
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-4 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
</div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons text-2xl">arrow_right</span>
</div>
</li>
</template>
</ul>
<ul v-show="sublist" class="h-full w-full rounded-lg" role="listbox" aria-labelledby="listbox-label">
<li class="text-gray-50 select-none relative py-3 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons text-2xl">arrow_left</span>
</div>
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-lg">Back</span>
</div>
</li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
<div class="flex items-center justify-center">
<span class="font-normal block truncate py-3">No {{ sublist }}</span>
</div>
</li>
<template v-for="item in sublistItems">
<li :key="item" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item}` === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item)">
<div class="flex items-center">
<span class="font-normal truncate py-3 text-base">{{ snakeToNormal(item) }}</span>
</div>
</li>
</template>
</ul>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
filterBy: String
},
data() {
return {
sublist: null,
items: [
{
text: 'All',
value: 'all'
},
{
text: 'Genre',
value: 'genres',
sublist: true
},
{
text: 'Tag',
value: 'tags',
sublist: true
},
{
text: 'Series',
value: 'series',
sublist: true
}
]
}
},
watch: {
show(newVal) {
if (!newVal) {
if (this.sublist && !this.selectedItemSublist) this.sublist = null
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selected: {
get() {
return this.filterBy
},
set(val) {
this.$emit('update:filterBy', val)
}
},
selectedItemSublist() {
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
},
genres() {
return this.$store.state.audiobooks.genres
},
tags() {
return this.$store.state.audiobooks.tags
},
series() {
return this.$store.state.audiobooks.series
},
sublistItems() {
return this[this.sublist] || []
}
},
methods: {
clearSelected() {
this.selected = 'all'
this.show = false
this.$nextTick(() => this.$emit('change', 'all'))
},
snakeToNormal(kebab) {
if (!kebab) {
return 'err'
}
return String(kebab)
.split('_')
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
.join(' ')
},
clickedSublistOption(item) {
this.clickedOption({ value: `${this.sublist}.${item}` })
},
clickedOption(option) {
if (option.sublist) {
this.sublist = option.value
return
}
var val = option.value
if (this.selected === val) {
this.show = false
return
}
this.selected = val
this.show = false
this.$nextTick(() => this.$emit('change', val))
}
},
mounted() {}
}
</script>
<style>
.filter-modal-wrapper {
max-height: calc(100% - 320px);
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<template>
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-30 opacity-0">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<div class="absolute z-40 top-4 right-4 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
<span class="material-icons text-4xl">close</span>
</div>
<slot name="outer" />
<div ref="content" style="min-width: 90%; min-height: 200px" class="relative text-white max-h-screen" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickBg">
<slot />
</div>
</div>
</template>
<script>
export default {
props: {
value: Boolean,
processing: Boolean,
persistent: {
type: Boolean,
default: true
},
width: {
type: [String, Number],
default: 500
},
height: {
type: [String, Number],
default: 'unset'
}
},
data() {
return {
el: null,
content: null
}
},
watch: {
show(newVal) {
if (newVal) {
this.setShow()
} else {
this.setHide()
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
modalHeight() {
if (typeof this.height === 'string') {
return this.height
} else {
return this.height + 'px'
}
},
modalWidth() {
return typeof this.width === 'string' ? this.width : this.width + 'px'
}
},
methods: {
clickBg(vm, ev) {
if (this.processing && this.persistent) return
if (vm.srcElement.classList.contains('modal-bg')) {
this.show = false
}
},
setShow() {
document.body.appendChild(this.el)
setTimeout(() => {
this.content.style.transform = 'scale(1)'
}, 10)
document.documentElement.classList.add('modal-open')
},
setHide() {
this.content.style.transform = 'scale(0)'
this.el.remove()
document.documentElement.classList.remove('modal-open')
}
},
mounted() {
this.el = this.$refs.wrapper
this.content = this.$refs.content
this.content.style.transform = 'scale(0)'
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
this.el.style.opacity = 1
this.el.remove()
}
}
</script>
+96
View File
@@ -0,0 +1,96 @@
<template>
<modals-modal v-model="show" width="90%">
<div class="w-full h-full bg-primary rounded-lg border border-white border-opacity-20">
<ul class="w-full rounded-lg text-base" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-4 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-4xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
</li>
</template>
</ul>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
orderBy: String,
descending: Boolean
},
data() {
return {
items: [
{
text: 'Title',
value: 'book.title'
},
{
text: 'Author (First Last)',
value: 'book.authorFL'
},
{
text: 'Author (Last, First)',
value: 'book.authorLF'
},
{
text: 'Added At',
value: 'addedAt'
},
{
text: 'Duration',
value: 'duration'
},
{
text: 'Size',
value: 'size'
}
]
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selected: {
get() {
return this.orderBy
},
set(val) {
this.$emit('update:orderBy', val)
}
},
selectedDesc: {
get() {
return this.descending
},
set(val) {
this.$emit('update:descending', val)
}
}
},
methods: {
clickedOption(val) {
if (this.selected === val) {
this.selectedDesc = !this.selectedDesc
} else {
this.selected = val
}
this.show = false
this.$nextTick(() => this.$emit('change', val))
}
},
mounted() {}
}
</script>
+78
View File
@@ -0,0 +1,78 @@
<template>
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="loading" :type="type" :class="classList" @click="click">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</button>
</template>
<script>
export default {
props: {
color: {
type: String,
default: 'primary'
},
type: {
type: String,
default: ''
},
paddingX: Number,
small: Boolean,
loading: Boolean
},
data() {
return {}
},
computed: {
classList() {
var list = []
if (this.loading) list.push('text-opacity-0')
list.push('text-white')
list.push(`bg-${this.color}`)
if (this.small) {
list.push('text-sm')
if (this.paddingX === undefined) list.push('px-4')
list.push('py-1')
} else {
if (this.paddingX === undefined) list.push('px-8')
list.push('py-2')
}
if (this.paddingX !== undefined) {
list.push(`px-${this.paddingX}`)
}
return list
}
},
methods: {
click(e) {
this.$emit('click', e)
}
},
mounted() {}
}
</script>
<style>
button.btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<template>
<div class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="showMenu = !showMenu">
<span class="flex items-center">
<span class="block truncate">{{ label }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100">person</span>
</span>
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-gray-600 shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
<template v-for="item in items">
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div>
</li>
</nuxt-link>
<li v-else :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div>
</li>
</template>
</ul>
</transition>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
default: 'Menu'
},
items: {
type: Array,
default: () => []
}
},
data() {
return {
showMenu: false
}
},
methods: {
clickOutside() {
this.showMenu = false
},
clickedOption(itemValue) {
this.$emit('action', itemValue)
this.showMenu = false
}
},
mounted() {}
}
</script>
+29
View File
@@ -0,0 +1,29 @@
<template>
<input v-model="input" :type="type" :disabled="disabled" :placeholder="placeholder" class="px-2 py-1 bg-bg border border-gray-600 outline-none rounded-sm" :class="disabled ? 'text-gray-300' : 'text-white'" />
</template>
<script>
export default {
props: {
value: [String, Number],
placeholder: String,
type: String,
disabled: Boolean
},
data() {
return {}
},
computed: {
input: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {},
mounted() {}
}
</script>
+35
View File
@@ -0,0 +1,35 @@
<template>
<div class="w-full">
<p class="px-1 pb-1 text-sm font-semibold">{{ label }}</p>
<ui-text-input v-model="inputValue" :disabled="disabled" :type="type" class="w-full px-4 py-2" />
</div>
</template>
<script>
export default {
props: {
value: [String, Number],
label: String,
type: {
type: String,
default: 'text'
},
disabled: Boolean
},
data() {
return {}
},
computed: {
inputValue: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {},
mounted() {}
}
</script>