This commit is contained in:
advplyr 2021-08-17 17:01:11 -05:00
commit 6930e69b55
106 changed files with 26925 additions and 0 deletions

View file

@ -0,0 +1,435 @@
<template>
<div class="w-full">
<div class="w-full relative mb-4">
<div class="absolute left-2 top-0 bottom-0 h-full flex items-center">
<p ref="currentTimestamp" class="font-mono text-sm">00:00:00</p>
</div>
<div class="absolute right-2 top-0 bottom-0 h-full flex items-center">
<p class="font-mono text-sm">{{ totalDurationPretty }}</p>
</div>
<div class="absolute right-24 top-0 bottom-0">
<controls-volume-control v-model="volume" @input="updateVolume" />
</div>
<div class="flex my-2">
<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">2x</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>
</div>
<div class="relative">
<!-- 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' : ''" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @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>
<!-- Hover timestamp -->
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5">00:00</p>
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
<div class="arrow-down" />
</div>
</div>
</div>
<audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" />
</div>
</template>
<script>
import Hls from 'hls.js'
export default {
props: {
loading: Boolean
},
data() {
return {
hlsInstance: null,
staleHlsInstance: null,
volume: 0.5,
trackWidth: 0,
isPaused: true,
url: null,
src: null,
playedTrackWidth: 0,
bufferTrackWidth: 0,
readyTrackWidth: 0,
audioEl: null,
totalDuration: 0,
seekedTime: 0,
seekLoading: false
}
},
computed: {
token() {
return this.$store.getters.getToken
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
}
},
methods: {
seek(time) {
if (this.seekLoading) {
console.error('Already seek loading', this.seekedTime)
return
}
if (!this.audioEl) {
console.error('No Audio el for seek', time)
return
}
this.seekedTime = time
this.seekLoading = true
console.warn('SEEK TO', this.$secondsToTimestamp(time))
this.audioEl.currentTime = time
if (this.$refs.playedTrack) {
var perc = time / this.audioEl.duration
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) {
if (this.audioEl) {
this.audioEl.volume = 1 - volume
}
},
mousemoveTrack(e) {
var offsetX = e.offsetX
var time = (offsetX / this.trackWidth) * this.totalDuration
if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth
this.$refs.hoverTimestamp.style.opacity = 1
this.$refs.hoverTimestamp.style.left = offsetX - width / 2 + 'px'
}
if (this.$refs.hoverTimestampText) {
this.$refs.hoverTimestampText.innerText = this.$secondsToTimestamp(time)
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 1
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
}
},
mouseleaveTrack() {
if (this.$refs.hoverTimestamp) {
this.$refs.hoverTimestamp.style.opacity = 0
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 0
}
},
restart() {
this.seek(0)
},
backward10() {
var newTime = this.audioEl.currentTime - 10
newTime = Math.max(0, newTime)
this.seek(newTime)
},
forward10() {
var newTime = this.audioEl.currentTime + 10
newTime = Math.min(this.audioEl.duration, newTime)
this.seek(newTime)
},
sendStreamUpdate() {
if (!this.audioEl) return
this.$emit('updateTime', this.audioEl.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
}
if (!this.audioEl) {
console.error('No Audio El')
return
}
var currTimeClean = this.$secondsToTimestamp(this.audioEl.currentTime)
ts.innerText = currTimeClean
},
clickTrack(e) {
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
var time = perc * this.audioEl.duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
}
this.seek(time)
},
playPauseClick() {
if (this.isPaused) {
this.play()
} else {
this.pause()
}
},
isValidDuration(duration) {
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
return true
}
return false
},
getBufferedRanges() {
if (!this.audioEl) return []
const ranges = []
const seekable = this.audioEl.buffered || []
let offset = 0
for (let i = 0, length = seekable.length; i < length; i++) {
let start = seekable.start(i)
let end = seekable.end(i)
if (!this.isValidDuration(start)) {
start = 0
}
if (!this.isValidDuration(end)) {
end = 0
continue
}
ranges.push({
start: start + offset,
end: end + offset
})
}
return ranges
},
getLastBufferedTime() {
var bufferedRanges = this.getBufferedRanges()
if (!bufferedRanges.length) return 0
var buff = bufferedRanges.find((buff) => buff.start < this.audioEl.currentTime && buff.end > this.audioEl.currentTime)
if (buff) return buff.end
var last = bufferedRanges[bufferedRanges.length - 1]
return last.end
},
progress() {
if (!this.audioEl) {
return
}
var lastbuff = this.getLastBufferedTime()
this.sendStreamUpdate()
var bufferlen = (lastbuff / this.audioEl.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
this.$refs.bufferTrack.style.width = bufferlen + 'px'
this.bufferTrackWidth = bufferlen
},
timeupdate() {
// console.log('Time update', this.audioEl.currentTime)
if (!this.$refs.playedTrack) {
console.error('Invalid no played track ref')
return
}
if (!this.audioEl) {
console.error('No Audio El')
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()
var perc = this.audioEl.currentTime / this.audioEl.duration
var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
}
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
},
paused() {
if (!this.$refs.audio) {
console.error('No audio on paused()')
return
}
console.log('Paused')
this.isPaused = this.$refs.audio.paused
},
playing() {
if (!this.$refs.audio) {
console.error('No audio on playing()')
return
}
this.isPaused = this.$refs.audio.paused
},
audioLoadedData() {
this.totalDuration = this.audioEl.duration
},
set(url, currentTime, playOnLoad = false) {
console.log('[AudioPlayer] SET PlayOnLoad ', playOnLoad)
if (this.hlsInstance) {
this.terminateStream()
}
if (!this.$refs.audio) {
console.error('No audio widget')
return
}
this.url = url
if (process.env.NODE_ENV === 'development') {
url = `${process.env.serverUrl}${url}`
}
this.src = url
console.log('[AudioPlayer-Set] Set url', url)
var hlsOptions = {
startPosition: currentTime || -1,
xhrSetup: (xhr) => {
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
}
}
console.log('[AudioPlayer-Set] HLS Config', hlsOptions, this.$secondsToTimestamp(hlsOptions.startPosition))
this.hlsInstance = new Hls(hlsOptions)
var audio = this.$refs.audio
audio.volume = this.volume
this.hlsInstance.attachMedia(audio)
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
console.log('[HLS] MEDIA ATTACHED')
this.hlsInstance.loadSource(url)
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, function () {
console.log('[HLS] Manifest Parsed')
if (playOnLoad) {
audio.play()
}
})
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
console.error('[HLS] Error', data.type, data.details)
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
console.error('[HLS] BUFFER STALLED ERROR')
}
})
this.hlsInstance.on(Hls.Events.FRAG_LOADED, (e, data) => {
var frag = data.frag
console.log('[HLS] Frag Loaded', frag.sn, this.$secondsToTimestamp(frag.start), frag)
})
this.hlsInstance.on(Hls.Events.STREAM_STATE_TRANSITION, (e, data) => {
console.log('[HLS] Stream State Transition', data)
})
this.hlsInstance.on(Hls.Events.BUFFER_APPENDED, (e, data) => {
// console.log('[HLS] BUFFER', data)
})
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
console.warn('[HLS] Destroying HLS Instance')
})
})
},
play() {
if (!this.$refs.audio) {
console.error('No Audio ref')
return
}
this.$refs.audio.play()
},
pause() {
if (!this.$refs.audio) return
this.$refs.audio.pause()
},
terminateStream() {
if (this.hlsInstance) {
if (!this.hlsInstance.destroy) {
console.error('HLS Instance has no destroy property', this.hlsInstance)
return
}
this.staleHlsInstance = this.hlsInstance
this.staleHlsInstance.destroy()
this.hlsInstance = null
console.log('Terminated HLS Instance', this.staleHlsInstance)
}
},
async resetStream(startTime) {
if (this.$refs.audio) this.$refs.audio.pause()
this.terminateStream()
await new Promise((resolve) => setTimeout(resolve, 1000))
console.log('Waited 1 second after terminating stream to start again')
this.set(this.url, startTime, true)
},
init() {
this.audioEl = this.$refs.audio
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
}
},
mounted() {
this.$nextTick(this.init)
}
}
</script>
<style scoped>
.arrow-down {
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid white;
}
</style>

View file

@ -0,0 +1,88 @@
<template>
<div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-10">
<div class="flex h-full items-center">
<img v-if="!showBack" src="/LogoTransparent.png" class="w-12 h-12 mr-4" />
<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-4 cursor-pointer">
<span class="material-icons text-4xl text-white">arrow_back</span>
</a>
<h1 class="text-2xl font-book">AudioBookshelf</h1>
<div class="flex-grow" />
<!-- <button class="px-4 py-2 bg-blue-500 rounded-xs" @click="scan">Scan</button> -->
<nuxt-link to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
<span class="material-icons">settings</span>
</nuxt-link>
<ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" />
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
menuItems: [
// {
// value: 'settings',
// text: 'Settings'
// },
{
value: 'logout',
text: 'Logout'
}
]
}
},
computed: {
showBack() {
return this.$route.name !== 'index'
},
user() {
return this.$store.state.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('/')
}
},
scan() {
console.log('Call Start Init')
this.$root.socket.emit('scan')
},
logout() {
this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
if (localStorage.getItem('token')) {
localStorage.removeItem('token')
}
this.$router.push('/login')
},
menuAction(action) {
if (action === 'logout') {
this.logout()
} else if (action === 'settings') {
// Show settings modal
}
}
},
mounted() {}
}
</script>
<style>
#appbar {
box-shadow: 0px 8px 8px #111111aa;
}
</style>

View file

@ -0,0 +1,102 @@
<template>
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto">
<div class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in groupedBooks">
<div :key="index" class="w-full bookshelfRow relative">
<div class="flex justify-center items-center">
<template v-for="audiobook in shelf">
<cards-book-card :ref="`audiobookCard-${audiobook.id}`" :key="audiobook.id" :user-progress="userAudiobooks[audiobook.id]" :audiobook="audiobook" />
</template>
</div>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
</div>
</template>
</div>
</div>
</template>
<script>
export default {
data() {
return {
width: 0,
bookWidth: 176,
booksPerRow: 0,
groupedBooks: []
}
},
computed: {
userAudiobooks() {
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {}
},
audiobooks() {
return this.$store.state.audiobooks.audiobooks
}
},
methods: {
setGroupedBooks() {
var groups = []
var currentRow = 0
var currentGroup = []
for (let i = 0; i < this.audiobooks.length; i++) {
var row = Math.floor(i / this.booksPerRow)
if (row > currentRow) {
groups.push([...currentGroup])
currentRow = row
currentGroup = []
}
currentGroup.push(this.audiobooks[i])
}
if (currentGroup.length) {
groups.push([...currentGroup])
}
this.groupedBooks = groups
},
calculateBookshelf() {
this.width = this.$refs.wrapper.clientWidth
var booksPerRow = Math.floor(this.width / this.bookWidth)
this.booksPerRow = booksPerRow
},
getAudiobookCard(id) {
if (this.$refs[`audiobookCard-${id}`] && this.$refs[`audiobookCard-${id}`].length) {
return this.$refs[`audiobookCard-${id}`][0]
}
return null
},
init() {
this.calculateBookshelf()
},
resize() {
this.$nextTick(() => {
this.calculateBookshelf()
this.setGroupedBooks()
})
},
audiobooksUpdated() {
console.log('[AudioBookshelf] Audiobooks Updated')
this.setGroupedBooks()
}
},
mounted() {
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
this.$store.dispatch('audiobooks/load')
this.init()
window.addEventListener('resize', this.resize)
},
beforeDestroy() {
this.$store.commit('audiobooks/removeListener', 'bookshelf')
window.removeEventListener('resize', this.resize)
}
}
</script>
<style>
.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>

View file

@ -0,0 +1,142 @@
<template>
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-20 bg-primary p-4">
<div class="absolute -top-16 left-4">
<cards-book-cover :audiobook="streamAudiobook" :width="88" />
</div>
<div class="flex items-center pl-24">
<div>
<h1>
{{ title }} <span v-if="stream" class="text-xs text-gray-400">({{ stream.id }})</span>
</h1>
<p class="text-gray-400 text-sm">by {{ author }}</p>
</div>
<div class="flex-grow" />
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
</div>
<audio-player ref="audioPlayer" :loading="!stream" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
</div>
</template>
<script>
export default {
data() {
return {
lastServerUpdateSentSeconds: 0,
stream: null
}
},
computed: {
cover() {
if (this.streamAudiobook && this.streamAudiobook.cover) return this.streamAudiobook.cover
return 'Logo.png'
},
user() {
return this.$store.state.user
},
streamAudiobook() {
return this.$store.state.streamAudiobook
},
book() {
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
},
title() {
return this.book.title || 'No Title'
},
author() {
return this.book.author || 'Unknown'
},
streamId() {
return this.stream ? this.stream.id : null
},
playlistUrl() {
return this.stream ? this.stream.clientPlaylistUri : null
}
},
methods: {
audioPlayerMounted() {
if (this.stream) {
// this.$refs.audioPlayer.set(this.playlistUrl)
this.openStream()
}
},
cancelStream() {
this.$root.socket.emit('close_stream')
},
terminateStream() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
},
openStream() {
var playOnLoad = this.$store.state.playOnLoad
console.log(`[StreamContainer] openStream PlayOnLoad`, playOnLoad)
if (!this.$refs.audioPlayer) {
console.error('NO Audio Player')
return
}
var currentTime = this.stream.clientCurrentTime || 0
this.$refs.audioPlayer.set(this.playlistUrl, currentTime, playOnLoad)
},
streamProgress(data) {
if (!data.numSegments) return
var chunks = data.chunks
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
}
},
streamOpen(stream) {
this.stream = stream
this.$nextTick(() => {
this.openStream()
})
},
streamClosed(streamId) {
if (this.stream && this.stream.id === streamId) {
this.terminateStream()
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
}
},
streamReady() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setStreamReady()
}
},
updateTime(currentTime) {
var diff = currentTime - this.lastServerUpdateSentSeconds
if (diff > 4 || diff < 0) {
this.lastServerUpdateSentSeconds = currentTime
var updatePayload = {
currentTime,
streamId: this.streamId
}
this.$root.socket.emit('stream_update', updatePayload)
}
},
streamReset({ startTime, streamId }) {
if (streamId !== this.streamId) {
console.error('resetStream StreamId Mismatch', streamId, this.streamId)
return
}
if (this.$refs.audioPlayer) {
console.log(`[STREAM-CONTAINER] streamReset Received for time ${startTime}`)
this.$refs.audioPlayer.resetStream(startTime)
}
}
},
mounted() {
if (this.stream) {
console.log('[STREAM_CONTAINER] Mounted with STREAM', this.stream)
this.$nextTick(() => {
this.openStream()
})
}
}
}
</script>
<style>
#streamContainer {
box-shadow: 0px -6px 8px #1111113f;
}
</style>

View file

@ -0,0 +1,67 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-4">Audio Tracks</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span>
<div class="flex-grow" />
<nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small color="primary">Edit Track Order</ui-btn>
</nuxt-link>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span>
</div>
</div>
<transition name="slide">
<div class="w-full" v-show="showTracks">
<table class="text-sm tracksTable">
<tr class="font-book">
<th>#</th>
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-book">
{{ track.filename }}
</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
</tr>
</template>
</table>
</div>
</transition>
</div>
</template>
<script>
export default {
props: {
tracks: {
type: Array,
default: () => []
},
audiobookId: String
},
data() {
return {
showTracks: false
}
},
computed: {},
methods: {
clickBar() {
this.showTracks = !this.showTracks
}
},
mounted() {}
}
</script>

View file

@ -0,0 +1,110 @@
<template>
<nuxt-link :to="`/audiobook/${audiobookId}`" :style="{ height: height + 32 + 'px', width: width + 32 + 'px' }" class="cursor-pointer p-4">
<div class="rounded-sm h-full overflow-hidden relative bookCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<div class="w-full relative" :style="{ height: width * 1.6 + 'px' }">
<cards-book-cover :audiobook="audiobook" />
<div v-show="isHovering" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
<div class="h-full flex items-center justify-center">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
<span class="material-icons text-5xl">play_circle_filled</span>
</div>
</div>
<div class="absolute top-1.5 right-1.5 cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" @click.stop.prevent="editClick">
<span class="material-icons" style="font-size: 16px">edit</span>
</div>
</div>
<div class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
</div>
<ui-tooltip v-if="showError" :text="errorText" class="absolute top-4 left-0">
<div class="h-6 w-10 bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-icons text-sm text-red-100 pr-1">priority_high</span>
</div>
</ui-tooltip>
</div>
</nuxt-link>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => null
},
userProgress: {
type: Object,
default: () => null
}
},
data() {
return {
isHovering: false
}
},
computed: {
audiobookId() {
return this.audiobook.id
},
book() {
return this.audiobook.book || {}
},
width() {
return 120
},
height() {
return this.width * 1.6
},
title() {
return this.book.title
},
author() {
return this.book.author
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
},
showError() {
return this.hasMissingParts || this.hasInvalidParts
},
hasMissingParts() {
return this.audiobook.hasMissingParts
},
hasInvalidParts() {
return this.audiobook.hasInvalidParts
},
errorText() {
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
}
if (this.hasInvalidParts) {
if (this.hasMissingParts) txt += ' '
txt += `${this.hasInvalidParts} invalid parts.`
}
return txt || 'Unknown Error'
}
},
methods: {
clickError(e) {
e.stopPropagation()
this.$router.push(`/audiobook/${this.audiobookId}`)
},
play() {
this.$store.commit('setStreamAudiobook', this.audiobook)
this.$root.socket.emit('open_stream', this.audiobookId)
},
editClick() {
this.$store.commit('showEditModal', this.audiobook)
}
},
mounted() {}
}
</script>
<style>
.bookCard {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
</style>

View file

@ -0,0 +1,72 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px' }">
<img ref="cover" :src="cover" class="w-full h-full object-cover" />
<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' }">{{ author }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
},
width: {
type: Number,
default: 120
}
},
data() {
return {}
},
computed: {
book() {
return this.audiobook.book || {}
},
title() {
return this.book.title || 'No Title'
},
titleCleaned() {
if (this.title.length > 75) {
return this.title.slice(0, 47) + '...'
}
return this.title
},
author() {
return this.book.author || 'Unknown'
},
cover() {
return this.book.cover || '/book_placeholder.jpg'
},
hasCover() {
return !!this.book.cover
},
fontSizeMultiplier() {
return this.width / 120
},
titleFontSize() {
return 0.75 * this.fontSizeMultiplier
},
authorFontSize() {
return 0.6 * this.fontSizeMultiplier
},
placeholderCoverPadding() {
return 0.8 * this.fontSizeMultiplier
},
authorBottom() {
return 0.75 * this.fontSizeMultiplier
}
},
methods: {},
mounted() {}
}
</script>

View file

@ -0,0 +1,89 @@
<template>
<div class="relative" v-click-outside="clickOutside">
<div class="cursor-pointer" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
<span class="material-icons text-3xl">volume_up</span>
</div>
<div v-show="isOpen" class="absolute bottom-10 left-0 h-28 py-2 bg-white shadow-sm rounded-lg">
<div ref="volumeTrack" class="w-2 border-2 border-white h-full bg-gray-400 mx-4 relative cursor-pointer" @mousedown="mousedownTrack" @click="clickVolumeTrack">
<div class="w-3 h-3 bg-gray-500 shadow-sm rounded-full absolute -left-1 bottom-0 pointer-events-none" :class="isDragging ? 'transform scale-150' : ''" :style="{ top: cursorTop + 'px' }" />
</div>
</div>
</div>
</template>
<script>
export default {
props: {
value: Number
},
data() {
return {
isOpen: false,
isDragging: false,
posY: 0,
trackHeight: 112 - 16
}
},
computed: {
volume: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
cursorTop() {
var top = this.trackHeight * this.volume
return top - 6
}
},
methods: {
mousemove(e) {
var diff = this.posY - e.y
this.posY = e.y
var volShift = 0
if (diff < 0) {
// Volume up
volShift = diff / this.trackHeight
} else {
// volume down
volShift = diff / this.trackHeight
}
var newVol = this.volume - volShift
newVol = Math.min(Math.max(0, newVol), 1)
this.volume = newVol
e.preventDefault()
},
mouseup(e) {
if (this.isDragging) {
this.isDragging = false
document.body.removeEventListener('mousemove', this.mousemove)
document.body.removeEventListener('mouseup', this.mouseup)
}
},
mousedownTrack(e) {
this.isDragging = true
this.posY = e.y
var vol = e.offsetY / e.target.clientHeight
vol = Math.min(Math.max(vol, 0), 1)
this.volume = vol
document.body.addEventListener('mousemove', this.mousemove)
document.body.addEventListener('mouseup', this.mouseup)
e.preventDefault()
},
clickOutside() {
this.isOpen = false
},
clickVolumeIcon() {
this.isOpen = !this.isOpen
},
clickVolumeTrack(e) {
var vol = e.offsetY / e.target.clientHeight
vol = Math.min(Math.max(vol, 0), 1)
this.volume = vol
}
},
mounted() {}
}
</script>

View file

@ -0,0 +1,86 @@
<template>
<modals-modal v-model="show" :width="800" :height="500" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 w-full flex">
<div class="h-10 w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300" :class="selectedTab === 'details' ? 'bg-bg' : 'bg-primary text-gray-400'" @click="selectTab('details')">Details</div>
<div class="h-10 w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300" :class="selectedTab === 'cover' ? 'bg-bg' : 'bg-primary text-gray-400'" @click="selectTab('cover')">Cover</div>
<div class="h-10 w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300" :class="selectedTab === 'match' ? 'bg-bg' : 'bg-primary text-gray-400'" @click="selectTab('match')">Match</div>
<div class="h-10 w-28 rounded-t-lg flex items-center justify-center cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300" :class="selectedTab === 'tracks' ? 'bg-bg' : 'bg-primary text-gray-400'" @click="selectTab('tracks')">Tracks</div>
</div>
<div class="px-4 w-full h-full text-sm py-6 rounded-b-lg rounded-tr-lg bg-bg shadow-lg">
<keep-alive>
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" />
</keep-alive>
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
selectedTab: 'details',
processing: false,
audiobook: null
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) return
this.audiobook = null
this.fetchFull()
}
}
}
},
computed: {
show: {
get() {
return this.$store.state.showEditModal
},
set(val) {
this.$store.commit('setShowEditModal', val)
}
},
tabName() {
if (this.selectedTab === 'details') return 'modals-edit-tabs-details'
else if (this.selectedTab === 'cover') return 'modals-edit-tabs-cover'
else if (this.selectedTab === 'match') return 'modals-edit-tabs-match'
else if (this.selectedTab === 'tracks') return 'modals-edit-tabs-tracks'
return ''
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook || {}
},
selectedAudiobookId() {
return this.selectedAudiobook.id
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
title() {
return this.book.title || 'No Title'
}
},
methods: {
selectTab(tab) {
this.selectedTab = tab
},
async fetchFull() {
try {
this.audiobook = await this.$axios.$get(`/api/audiobook/${this.selectedAudiobookId}`)
} catch (error) {
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
this.show = false
}
}
},
mounted() {}
}
</script>

View file

@ -0,0 +1,100 @@
<template>
<div ref="wrapper" class="modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-50 flex items-center justify-center z-20 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 top-5 right-5 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: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickBg">
<slot />
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
<ui-loading-indicator />
</div>
</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>

View file

@ -0,0 +1,42 @@
<template>
<div class="w-full h-full">
<!-- <img :src="cover" class="w-40 h-60" /> -->
<div class="flex">
<cards-book-cover :audiobook="audiobook" />
<div class="flex-grow px-8">
<ui-text-input-with-label v-model="imageUrl" label="Image URL" />
</div>
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
cover: null,
imageUrl: null
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {},
methods: {
init() {
this.cover = this.audiobook.cover || '/book_placeholder.jpg'
}
}
}
</script>

View file

@ -0,0 +1,135 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
<div v-if="userProgress" class="bg-primary rounded-md w-full px-4 py-1 mb-4 border border-success border-opacity-50">
<div class="w-full flex items-center">
<p>
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
</p>
<div class="flex-grow" />
<ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn>
</div>
</div>
<form @submit.prevent="submitForm">
<ui-text-input-with-label v-model="details.title" label="Title" />
<ui-text-input-with-label v-model="details.author" label="Author" class="mt-4" />
<ui-textarea-with-label v-model="details.description" :rows="6" label="Description" class="mt-4" />
<div class="flex py-4">
<ui-btn color="error" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn>
</div>
</form>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
details: {
title: null,
description: null,
author: null
},
resettingProgress: false
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
userAudiobook() {
return this.$store.getters['getUserAudiobook'](this.audiobookId)
},
userProgress() {
return this.userAudiobook ? this.userAudiobook.progress : 0
}
},
methods: {
async submitForm() {
console.log('Submit form', this.details)
this.isProcessing = true
const updatePayload = {
book: this.details
}
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
this.isProcessing = false
if (updatedAudiobook) {
console.log('Update Successful', updatedAudiobook)
this.$toast.success('Update Successful')
this.$emit('close')
}
},
init() {
this.details.title = this.book.title
this.details.description = this.book.description
this.details.author = this.book.author
},
resetProgress() {
if (confirm(`Are you sure you want to reset your progress?`)) {
this.resettingProgress = true
this.$axios
.$delete(`/api/user/audiobook/${this.audiobookId}`)
.then(() => {
console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`)
this.resettingProgress = false
})
.catch((error) => {
console.error('Progress reset failed', error)
this.resettingProgress = false
})
}
},
deleteAudiobook() {
if (confirm(`Are you sure you want to remove this audiobook?`)) {
this.isProcessing = true
this.$axios
.$delete(`/api/audiobook/${this.audiobookId}`)
.then(() => {
console.log('Audiobook removed')
this.$toast.success('Audiobook Removed')
this.$emit('close')
this.isProcessing = false
})
.catch((error) => {
console.error('Remove Audiobook failed', error)
this.isProcessing = false
})
}
}
}
}
</script>

View file

@ -0,0 +1,100 @@
<template>
<div class="w-full h-full overflow-hidden">
<div class="flex items-center mb-4">
<div class="w-72">
<form @submit.prevent="submitSearch">
<ui-text-input-with-label v-model="search" label="Search Title" placeholder="Search" :disabled="processing" />
</form>
</div>
<div class="flex-grow" />
</div>
<div v-show="processing" class="flex h-full items-center justify-center">
<p>Loading...</p>
</div>
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden">
<template v-for="(res, index) in searchResults">
<div :key="index" class="w-full border-b border-gray-700 pb-2 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch(res)">
<div class="flex py-1">
<img :src="res.cover || '/book_placeholder.jpg'" class="h-24 object-cover" style="width: 60px" />
<div class="px-4 flex-grow">
<div class="flex items-center">
<h1>{{ res.title }}</h1>
<div class="flex-grow" />
<p>{{ res.first_publish_year || res.first_publish_date }}</p>
</div>
<p class="text-gray-400">{{ res.author }}</p>
<div class="w-full max-h-12 overflow-hidden">
<p class="text-gray-500 text-xs" v-html="res.description"></p>
</div>
</div>
</div>
<div v-if="res.covers && res.covers.length > 1" class="flex">
<template v-for="cover in res.covers.slice(1)">
<img :key="cover" :src="cover" class="h-20 w-12 object-cover mr-1" />
</template>
</div>
</div>
</template>
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
search: null,
lastSearch: null,
processing: false,
searchResults: []
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {},
methods: {
submitSearch() {
this.runSearch()
},
async runSearch() {
if (this.lastSearch === this.search) return
console.log('Search', this.lastSearch, this.search)
this.searchResults = []
this.processing = true
this.lastSearch = this.search
var results = await this.$axios.$get(`/api/find/search?title=${this.search}`)
console.log('Got results', results)
this.searchResults = results
this.processing = false
},
init() {
if (!this.audiobook.book || !this.audiobook.book.title) {
this.search = null
return
}
if (this.searchResults.length) {
console.log('Already hav ereuslts', this.searchResults, this.lastSearch)
}
this.search = this.audiobook.book.title
this.runSearch()
},
selectMatch(match) {}
},
mounted() {
console.log('Match mounted')
}
}
</script>

View file

@ -0,0 +1,66 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden">
<div class="flex mb-4">
<nuxt-link :to="`/audiobook/${audiobook.id}/edit`">
<ui-btn color="primary">Edit Track Order</ui-btn>
</nuxt-link>
</div>
<table class="text-sm tracksTable">
<tr class="font-book">
<th>#</th>
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-book">
{{ track.filename }}
</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
</tr>
</template>
</table>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
tracks: null,
audioFiles: null
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {},
methods: {
init() {
this.audioFiles = this.audiobook.audioFiles
this.tracks = this.audiobook.tracks
console.log('INIT', this.audiobook)
}
}
}
</script>

View file

@ -0,0 +1,67 @@
<template>
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :type="type" :class="classList" @click="click">
<slot />
</button>
</template>
<script>
export default {
props: {
color: {
type: String,
default: 'primary'
},
type: {
type: String,
default: ''
},
paddingX: Number,
small: Boolean
},
data() {
return {}
},
computed: {
classList() {
var list = []
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::before {
background-color: rgba(255, 255, 255, 0.1);
}
</style>

View file

@ -0,0 +1,70 @@
<template>
<div class="w-40">
<div class="bg-white border py-2 px-5 rounded-lg flex items-center flex-col">
<div class="loader-dots block relative w-20 h-5 mt-2">
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div class="text-gray-500 text-xs font-light mt-2 text-center">{{ text }}</div>
</div>
</div>
</template>
<script>
export default {
props: {
text: {
type: String,
default: 'Please Wait...'
}
}
}
</script>
<style>
.loader-dots div {
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.loader-dots div:nth-child(1) {
left: 8px;
animation: loader-dots1 0.6s infinite;
}
.loader-dots div:nth-child(2) {
left: 8px;
animation: loader-dots2 0.6s infinite;
}
.loader-dots div:nth-child(3) {
left: 32px;
animation: loader-dots2 0.6s infinite;
}
.loader-dots div:nth-child(4) {
left: 56px;
animation: loader-dots3 0.6s infinite;
}
@keyframes loader-dots1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes loader-dots3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes loader-dots2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
</style>

View file

@ -0,0 +1,54 @@
<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-black-200 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">
<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>
</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>

View file

@ -0,0 +1,47 @@
<template>
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
</template>
<script>
export default {
props: {
value: [String, Number],
placeholder: String,
readonly: Boolean,
type: {
type: String,
default: 'text'
},
transparent: Boolean,
disabled: Boolean
},
data() {
return {}
},
computed: {
inputValue: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
change(e) {
this.$emit('change', e.target.value)
}
},
mounted() {}
}
</script>
<style scoped>
input {
border-style: inherit !important;
}
input:read-only {
background-color: #444;
}
</style>

View file

@ -0,0 +1,31 @@
<template>
<div class="w-full">
<p class="px-1">{{ label }}</p>
<ui-text-input v-model="inputValue" :disabled="disabled" class="w-full" />
</div>
</template>
<script>
export default {
props: {
value: [String, Number],
label: String,
disabled: Boolean
},
data() {
return {}
},
computed: {
inputValue: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {},
mounted() {}
}
</script>

View file

@ -0,0 +1,47 @@
<template>
<textarea v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
</template>
<script>
export default {
props: {
value: [String, Number],
placeholder: String,
readonly: Boolean,
rows: {
type: Number,
default: 2
},
transparent: Boolean,
disabled: Boolean
},
data() {
return {}
},
computed: {
inputValue: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
change(e) {
this.$emit('change', e.target.value)
}
},
mounted() {}
}
</script>
<style scoped>
input {
border-style: inherit !important;
}
input:read-only {
background-color: #eee;
}
</style>

View file

@ -0,0 +1,34 @@
<template>
<div class="w-full">
<p class="px-1">{{ label }}</p>
<ui-textarea-input v-model="inputValue" :rows="rows" class="w-full" />
</div>
</template>
<script>
export default {
props: {
value: [String, Number],
label: String,
rows: {
type: Number,
default: 2
}
},
data() {
return {}
},
computed: {
inputValue: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {},
mounted() {}
}
</script>

View file

@ -0,0 +1,55 @@
<template>
<div ref="box" class="tooltip-box" @mouseover="mouseover" @mouseleave="mouseleave">
<slot />
</div>
</template>
<script>
export default {
props: {
text: {
type: String,
required: true
}
},
data() {
return {
tooltip: null,
isShowing: false
}
},
methods: {
createTooltip() {
var boxChow = this.$refs.box.getBoundingClientRect()
var top = boxChow.top
var left = boxChow.left + boxChow.width + 4
var tooltip = document.createElement('div')
tooltip.className = 'absolute px-2 bg-black bg-opacity-60 py-1 text-white pointer-events-none text-xs'
tooltip.style.top = top + 'px'
tooltip.style.left = left + 'px'
tooltip.style.zIndex = 100
tooltip.innerText = this.text
this.tooltip = tooltip
},
showTooltip() {
if (!this.tooltip) {
this.createTooltip()
}
document.body.appendChild(this.tooltip)
this.isShowing = true
},
hideTooltip() {
if (!this.tooltip) return
this.tooltip.remove()
this.isShowing = false
},
mouseover() {
if (!this.isShowing) this.showTooltip()
},
mouseleave() {
if (this.isShowing) this.hideTooltip()
}
}
}
</script>

View file

@ -0,0 +1,37 @@
<template>
<div v-show="isScanning" class="fixed bottom-0 left-0 right-0 mx-auto z-20 max-w-lg">
<div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning">
<p class="text-lg font-sans" v-html="text" />
</div>
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {
text() {
return `Scanning... <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
},
isScanning() {
return this.$store.state.isScanning
},
scanProgress() {
return this.$store.state.scanProgress
},
scanPercent() {
return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
},
scanNum() {
return this.scanProgress ? this.scanProgress.done : 0
},
scanTotal() {
return this.scanProgress ? this.scanProgress.total : 0
}
},
methods: {},
mounted() {}
}
</script>