mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-21 11:19:37 +00:00
Add:Create media item shares with expiration #1768
This commit is contained in:
parent
e52b695f7e
commit
d6eae9b43e
12 changed files with 801 additions and 104 deletions
195
client/components/modals/ShareModal.vue
Normal file
195
client/components/modals/ShareModal.vue
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="share" :width="600" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">Share media item</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<template v-if="currentShare">
|
||||
<div class="w-full py-2">
|
||||
<label class="px-1 text-sm font-semibold block">Share URL</label>
|
||||
<ui-text-input v-model="currentShareUrl" readonly class="text-base h-10" />
|
||||
</div>
|
||||
<div class="w-full py-2 px-1">
|
||||
<p v-if="currentShare.expiresAt" class="text-base">Expires in {{ currentShareTimeRemaining }}</p>
|
||||
<p v-else>Permanent</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between space-x-4">
|
||||
<div class="w-40">
|
||||
<label class="px-1 text-sm font-semibold block">Slug</label>
|
||||
<ui-text-input v-model="newShareSlug" class="text-base h-10" />
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="w-80">
|
||||
<label class="px-1 text-sm font-semibold block">Share Duration</label>
|
||||
<div class="inline-flex items-center space-x-2">
|
||||
<div>
|
||||
<ui-icon-btn icon="remove" :size="10" @click="clickMinus" />
|
||||
</div>
|
||||
<ui-text-input v-model="newShareDuration" type="number" text-center no-spinner class="text-center w-28 h-10 text-base" />
|
||||
<div>
|
||||
<ui-icon-btn icon="add" :size="10" @click="clickPlus" />
|
||||
</div>
|
||||
<ui-dropdown v-model="shareDurationUnit" :items="durationUnits" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-300 py-4 px-1">
|
||||
Share URL will be: <span class="">{{ demoShareUrl }}</span>
|
||||
</p>
|
||||
</template>
|
||||
<div class="flex items-center pt-6">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="currentShare" color="error" small @click="deleteShare">{{ $strings.ButtonDelete }}</ui-btn>
|
||||
<ui-btn v-if="!currentShare" color="success" small @click="openShare">{{ $strings.ButtonShare }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
mediaItemShare: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newShareSlug: '',
|
||||
newShareDuration: 0,
|
||||
currentShare: null,
|
||||
shareDurationUnit: 'minutes',
|
||||
durationUnits: [
|
||||
{
|
||||
text: 'Minutes',
|
||||
value: 'minutes'
|
||||
},
|
||||
{
|
||||
text: 'Hours',
|
||||
value: 'hours'
|
||||
},
|
||||
{
|
||||
text: 'Days',
|
||||
value: 'days'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
demoShareUrl() {
|
||||
return `${window.origin}/share/${this.newShareSlug}`
|
||||
},
|
||||
currentShareUrl() {
|
||||
if (!this.currentShare) return ''
|
||||
return `${window.origin}/share/${this.currentShare.slug}`
|
||||
},
|
||||
currentShareTimeRemaining() {
|
||||
if (!this.currentShare) return 'Error'
|
||||
if (!this.currentShare.expiresAt) return 'Permanent'
|
||||
const msRemaining = new Date(this.currentShare.expiresAt).valueOf() - Date.now()
|
||||
if (msRemaining <= 0) return 'Expired'
|
||||
return this.$elapsedPretty(msRemaining / 1000, true)
|
||||
},
|
||||
expireDurationSeconds() {
|
||||
let shareDuration = Number(this.newShareDuration)
|
||||
if (!shareDuration || isNaN(shareDuration)) return 0
|
||||
return this.newShareDuration * (this.shareDurationUnit === 'minutes' ? 60 : this.shareDurationUnit === 'hours' ? 3600 : 86400)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickPlus() {
|
||||
this.newShareDuration++
|
||||
},
|
||||
clickMinus() {
|
||||
if (this.newShareDuration > 0) {
|
||||
this.newShareDuration--
|
||||
}
|
||||
},
|
||||
deleteShare() {
|
||||
if (!this.currentShare) return
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/share/mediaitem/${this.currentShare.id}`)
|
||||
.then(() => {
|
||||
this.currentShare = null
|
||||
this.$emit('removed')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('deleteShare', error)
|
||||
let errorMsg = error.response?.data || 'Failed to delete share'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
openShare() {
|
||||
if (!this.newShareSlug) {
|
||||
this.$toast.error('Slug is required')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
slug: this.newShareSlug,
|
||||
mediaItemType: 'book',
|
||||
mediaItemId: this.libraryItem.media.id,
|
||||
expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0
|
||||
}
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/share/mediaitem`, payload)
|
||||
.then((data) => {
|
||||
this.currentShare = data
|
||||
this.$emit('opened', data)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('openShare', error)
|
||||
let errorMsg = error.response?.data || 'Failed to share item'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.newShareSlug = this.$randomId(10)
|
||||
if (this.mediaItemShare) {
|
||||
this.currentShare = { ...this.mediaItemShare }
|
||||
} else {
|
||||
this.currentShare = null
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -147,6 +147,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<modals-share-modal v-model="showShareModal" :media-item-share="mediaItemShare" :library-item="libraryItem" @opened="openedShare" @removed="removedShare" />
|
||||
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
|
||||
</div>
|
||||
|
|
@ -160,7 +161,7 @@ export default {
|
|||
}
|
||||
|
||||
// Include episode downloads for podcasts
|
||||
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed`).catch((error) => {
|
||||
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed,share`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
|
|
@ -170,7 +171,8 @@ export default {
|
|||
}
|
||||
return {
|
||||
libraryItem: item,
|
||||
rssFeed: item.rssFeed || null
|
||||
rssFeed: item.rssFeed || null,
|
||||
mediaItemShare: item.mediaItemShare || null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
|
@ -184,7 +186,8 @@ export default {
|
|||
episodeDownloadsQueued: [],
|
||||
showBookmarksModal: false,
|
||||
isDescriptionClamped: false,
|
||||
showFullDescription: false
|
||||
showFullDescription: false,
|
||||
showShareModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -437,6 +440,13 @@ export default {
|
|||
})
|
||||
}
|
||||
|
||||
if (this.userIsAdminOrUp && !this.isPodcast) {
|
||||
items.push({
|
||||
text: 'Share',
|
||||
action: 'share'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDelete,
|
||||
|
|
@ -448,6 +458,12 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
openedShare(mediaItemShare) {
|
||||
this.mediaItemShare = mediaItemShare
|
||||
},
|
||||
removedShare() {
|
||||
this.mediaItemShare = null
|
||||
},
|
||||
selectBookmark(bookmark) {
|
||||
if (!bookmark) return
|
||||
if (this.isStreaming) {
|
||||
|
|
@ -761,6 +777,8 @@ export default {
|
|||
this.deleteLibraryItem()
|
||||
} else if (action === 'sendToDevice') {
|
||||
this.sendToDevice(data)
|
||||
} else if (action === 'share') {
|
||||
this.showShareModal = true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
34
client/pages/share/_slug.vue
Normal file
34
client/pages/share/_slug.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<div id="page-wrapper" class="w-full h-screen overflow-y-auto">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<p class="text-xl">{{ mediaItemShare.mediaItem.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
layout: 'blank',
|
||||
async asyncData({ params, error, app }) {
|
||||
const mediaItemShare = await app.$axios.$get(`/public/share/${params.slug}`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
if (!mediaItemShare) {
|
||||
return error({ statusCode: 404, message: 'Not found' })
|
||||
}
|
||||
|
||||
return {
|
||||
mediaItemShare: mediaItemShare
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {
|
||||
console.log('Loaded media item share', this.mediaItemShare)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -2,7 +2,10 @@ import Vue from 'vue'
|
|||
import cronParser from 'cron-parser'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
Vue.prototype.$randomId = () => nanoid()
|
||||
Vue.prototype.$randomId = (len = null) => {
|
||||
if (len && !isNaN(len)) return nanoid(len)
|
||||
return nanoid()
|
||||
}
|
||||
|
||||
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||
if (isNaN(bytes) || bytes == 0) {
|
||||
|
|
@ -119,7 +122,7 @@ Vue.prototype.$parseCronExpression = (expression) => {
|
|||
value: '* * * * *'
|
||||
}
|
||||
]
|
||||
const patternMatch = commonPatterns.find(p => p.value === expression)
|
||||
const patternMatch = commonPatterns.find((p) => p.value === expression)
|
||||
if (patternMatch) {
|
||||
return {
|
||||
description: patternMatch.text
|
||||
|
|
@ -132,13 +135,17 @@ Vue.prototype.$parseCronExpression = (expression) => {
|
|||
if (pieces[2] !== '*' || pieces[3] !== '*') {
|
||||
return null
|
||||
}
|
||||
if (pieces[4] !== '*' && pieces[4].split(',').some(p => isNaN(p))) {
|
||||
if (pieces[4] !== '*' && pieces[4].split(',').some((p) => isNaN(p))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
var weekdayText = 'day'
|
||||
if (pieces[4] !== '*') weekdayText = pieces[4].split(',').map(p => weekdays[p]).join(', ')
|
||||
if (pieces[4] !== '*')
|
||||
weekdayText = pieces[4]
|
||||
.split(',')
|
||||
.map((p) => weekdays[p])
|
||||
.join(', ')
|
||||
|
||||
return {
|
||||
description: `Run every ${weekdayText} at ${pieces[1]}:${pieces[0].padStart(2, '0')}`
|
||||
|
|
@ -146,7 +153,7 @@ Vue.prototype.$parseCronExpression = (expression) => {
|
|||
}
|
||||
|
||||
Vue.prototype.$getNextScheduledDate = (expression) => {
|
||||
const interval = cronParser.parseExpression(expression);
|
||||
const interval = cronParser.parseExpression(expression)
|
||||
return interval.next().toDate()
|
||||
}
|
||||
|
||||
|
|
@ -171,10 +178,8 @@ Vue.prototype.$downloadFile = (url, filename = null, openInNewTab = false) => {
|
|||
|
||||
export function supplant(str, subs) {
|
||||
// source: http://crockford.com/javascript/remedial.html
|
||||
return str.replace(/{([^{}]*)}/g,
|
||||
function (a, b) {
|
||||
var r = subs[b]
|
||||
return typeof r === 'string' || typeof r === 'number' ? r : a
|
||||
}
|
||||
)
|
||||
return str.replace(/{([^{}]*)}/g, function (a, b) {
|
||||
var r = subs[b]
|
||||
return typeof r === 'string' || typeof r === 'number' ? r : a
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue