Add:Podcast create form #225

This commit is contained in:
advplyr 2022-12-08 17:54:56 -06:00
parent 705bbaccc1
commit 379fa21571
8 changed files with 582 additions and 12 deletions

View file

@ -0,0 +1,182 @@
<template>
<div class="w-full px-2">
<img v-if="podcast.imageUrl" :src="podcast.imageUrl" class="h-36 w-36 object-contain mx-auto mb-2" />
<ui-text-input-with-label v-model="podcast.title" :label="'Title'" class="mb-2 text-sm" @input="titleUpdated" />
<ui-text-input-with-label v-model="podcast.author" :label="'Author'" class="mb-2 text-sm" />
<ui-text-input-with-label v-model="podcast.feedUrl" :label="'Feed URL'" readonly class="mb-2 text-sm" />
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="'Genres'" class="mb-2 text-sm" />
<ui-textarea-with-label v-model="podcast.description" :label="'Description'" :rows="3" class="mb-2 text-sm" />
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" :label="'Folder'" class="mb-2 text-sm" @input="folderUpdated" />
<ui-text-input-with-label v-model="fullPath" :label="'Podcast Path'" input-class="h-10" readonly class="mb-2 text-sm" />
<div class="flex items-center py-4 px-2">
<ui-checkbox v-model="podcast.autoDownloadEpisodes" :label="'Auto Download Episodes'" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-sm font-semibold" />
<div class="flex-grow" />
<ui-btn color="success" @click="submit">Submit</ui-btn>
</div>
</div>
</template>
<script>
import Path from 'path'
export default {
props: {
processing: Boolean,
podcastData: {
type: Object,
default: () => null
},
podcastFeedData: {
type: Object,
default: () => null
}
},
data() {
return {
selectedFolderId: null,
fullPath: null,
podcast: {
title: '',
author: '',
description: '',
releaseDate: '',
genres: [],
feedUrl: '',
feedImageUrl: '',
itunesPageUrl: '',
itunesId: '',
itunesArtistId: '',
autoDownloadEpisodes: false
}
}
},
computed: {
_processing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
title() {
return this._podcastData.title
},
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
folders() {
if (!this.currentLibrary) return []
return this.currentLibrary.folders || []
},
folderItems() {
return this.folders.map((fold) => {
return {
value: fold.id,
text: fold.fullPath
}
})
},
_podcastData() {
return this.podcastData || {}
},
feedMetadata() {
if (!this.podcastFeedData) return {}
return this.podcastFeedData.metadata || {}
},
episodes() {
if (!this.podcastFeedData) return []
return this.podcastFeedData.episodes || []
},
selectedFolder() {
return this.folders.find((f) => f.id === this.selectedFolderId)
},
selectedFolderPath() {
if (!this.selectedFolder) return ''
return this.selectedFolder.fullPath
}
},
methods: {
titleUpdated() {
this.folderUpdated()
},
folderUpdated() {
if (!this.selectedFolderPath || !this.podcast.title) {
this.fullPath = ''
return
}
this.fullPath = Path.join(this.selectedFolderPath, this.$sanitizeFilename(this.podcast.title))
},
submit() {
const podcastPayload = {
path: this.fullPath,
folderId: this.selectedFolderId,
libraryId: this.currentLibrary.id,
media: {
metadata: {
title: this.podcast.title,
author: this.podcast.author,
description: this.podcast.description,
releaseDate: this.podcast.releaseDate,
genres: [...this.podcast.genres],
feedUrl: this.podcast.feedUrl,
imageUrl: this.podcast.imageUrl,
itunesPageUrl: this.podcast.itunesPageUrl,
itunesId: this.podcast.itunesId,
itunesArtistId: this.podcast.itunesArtistId,
language: this.podcast.language
},
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
}
}
console.log('Podcast payload', podcastPayload)
this._processing = true
this.$axios
.$post('/api/podcasts', podcastPayload)
.then((libraryItem) => {
this._processing = false
this.$toast.success('Podcast added')
this.$router.push(`/item/${libraryItem.id}`)
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to add podcast'
console.error('Failed to create podcast', error)
this._processing = false
this.$toast.error(errorMsg)
})
},
init() {
// Prefer using itunes podcast data but not always passed in if manually entering rss feed
this.podcast.title = this._podcastData.title || this.feedMetadata.title || ''
this.podcast.author = this._podcastData.artistName || this.feedMetadata.author || ''
this.podcast.description = this._podcastData.description || this.feedMetadata.descriptionPlain || ''
this.podcast.releaseDate = this._podcastData.releaseDate || ''
this.podcast.genres = this._podcastData.genres || this.feedMetadata.categories || []
this.podcast.feedUrl = this._podcastData.feedUrl || this.feedMetadata.feedUrl || ''
this.podcast.imageUrl = this._podcastData.cover || this.feedMetadata.image || ''
this.podcast.itunesPageUrl = this._podcastData.pageUrl || ''
this.podcast.itunesId = this._podcastData.id || ''
this.podcast.itunesArtistId = this._podcastData.artistId || ''
this.podcast.language = this._podcastData.language || ''
this.podcast.autoDownloadEpisodes = false
if (this.folderItems[0]) {
this.selectedFolderId = this.folderItems[0].value
this.folderUpdated()
}
}
},
mounted() {
this.init()
}
}
</script>

View file

@ -0,0 +1,261 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 px-1 bg-bg bg-opacity-75 flex items-center justify-end opacity-0 hover:opacity-100">
<span v-if="showEdit" class="material-icons text-white hover:text-warning cursor-pointer" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
<span class="material-icons text-white hover:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
</div>
{{ item }}
</div>
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div>
</form>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item }}</span>
</div>
<span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">checkmark</span>
</span>
</li>
</template>
<li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
<div class="flex items-center justify-center">
<span class="font-normal">No Items</span>
</div>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
props: {
value: {
type: Array,
default: () => []
},
items: {
type: Array,
default: () => []
},
label: String,
disabled: Boolean,
readonly: Boolean,
showEdit: Boolean
},
data() {
return {
textInput: null,
currentSearch: null,
typingTimeout: null,
isFocused: false,
menu: null
}
},
watch: {
showMenu(newVal) {
if (newVal) this.setListener()
else this.removeListener()
}
},
computed: {
selected: {
get() {
return this.value || []
},
set(val) {
this.$emit('input', val)
}
},
showMenu() {
return this.isFocused
},
wrapperClass() {
var classes = []
if (this.disabled) classes.push('bg-black-300')
else classes.push('bg-primary')
if (!this.readonly) classes.push('cursor-text')
return classes.join(' ')
},
itemsToShow() {
if (!this.currentSearch || !this.textInput) {
return this.items
}
return this.items.filter((i) => {
var iValue = String(i).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase())
})
}
},
methods: {
editItem(item) {
this.$emit('edit', item)
},
keydownInput() {
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {
this.currentSearch = this.textInput
}, 100)
this.setInputWidth()
},
setInputWidth() {
setTimeout(() => {
var value = this.$refs.input.value
var len = value.length * 7 + 24
this.$refs.input.style.width = len + 'px'
this.recalcMenuPos()
}, 50)
},
recalcMenuPos() {
if (!this.menu) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
if (boundingBox.y > window.innerHeight - 8) {
// Input is off the page
return this.forceBlur()
}
var menuHeight = this.menu.clientHeight
var top = boundingBox.y + boundingBox.height - 4
if (top + menuHeight > window.innerHeight - 20) {
// Reverse menu to open upwards
top = boundingBox.y - menuHeight - 4
}
this.menu.style.top = top + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
},
unmountMountMenu() {
if (!this.$refs.menu) return
this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
this.menu.remove()
document.body.appendChild(this.menu)
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
},
inputFocus() {
if (!this.menu) {
this.unmountMountMenu()
}
this.isFocused = true
this.$nextTick(this.recalcMenuPos)
},
inputBlur() {
if (!this.isFocused) return
setTimeout(() => {
if (document.activeElement === this.$refs.input) {
return
}
this.isFocused = false
if (this.textInput) this.submitForm()
}, 50)
},
focus() {
if (this.$refs.input) this.$refs.input.focus()
},
blur() {
if (this.$refs.input) this.$refs.input.blur()
},
forceBlur() {
this.isFocused = false
if (this.textInput) this.submitForm()
if (this.$refs.input) this.$refs.input.blur()
},
clickedOption(e, itemValue) {
if (e) {
e.stopPropagation()
e.preventDefault()
}
if (this.$refs.input) this.$refs.input.focus()
var newSelected = null
if (this.selected.includes(itemValue)) {
newSelected = this.selected.filter((s) => s !== itemValue)
this.$emit('removedItem', itemValue)
} else {
newSelected = this.selected.concat([itemValue])
}
this.textInput = null
this.currentSearch = null
this.$emit('input', newSelected)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
clickWrapper() {
if (this.disabled) return
if (this.showMenu) {
return this.blur()
}
this.focus()
},
removeItem(item) {
var remaining = this.selected.filter((i) => i !== item)
this.$emit('input', remaining)
this.$emit('removedItem', item)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
insertNewItem(item) {
this.selected.push(item)
this.$emit('input', this.selected)
this.$emit('newItem', item)
this.textInput = null
this.currentSearch = null
this.$nextTick(() => {
this.blur()
})
},
submitForm() {
if (!this.textInput) return
var cleaned = this.textInput.trim()
var matchesItem = this.items.find((i) => {
return i === cleaned
})
if (matchesItem) {
this.clickedOption(null, matchesItem)
} else {
this.insertNewItem(this.textInput)
}
},
scroll() {
this.recalcMenuPos()
},
setListener() {
document.addEventListener('scroll', this.scroll, true)
},
removeListener() {
document.removeEventListener('scroll', this.scroll, true)
}
},
mounted() {},
beforeDestroy() {
if (this.menu) this.menu.remove()
}
}
</script>
<style scoped>
input {
border-style: inherit !important;
}
input:read-only {
color: #aaa;
background-color: #444;
}
</style>

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="relative"> <div class="relative">
<input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none" :class="inputClass" @keyup="keyup" /> <input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none bg-primary" :class="inputClass" @keyup="keyup" />
<div v-if="prependIcon" class="absolute top-0 left-0 h-full px-2 flex items-center justify-center"> <div v-if="prependIcon" class="absolute top-0 left-0 h-full px-2 flex items-center justify-center">
<span class="material-icons text-lg">{{ prependIcon }}</span> <span class="material-icons text-lg">{{ prependIcon }}</span>
</div> </div>
@ -26,10 +26,6 @@ export default {
prependIcon: { prependIcon: {
type: String, type: String,
default: null default: null
},
textSize: {
type: String,
default: 'lg'
} }
}, },
data() { data() {
@ -45,7 +41,7 @@ export default {
} }
}, },
inputClass() { inputClass() {
var classes = [`bg-${this.bg}`, `rounded-${this.rounded}`, `text-${this.textSize}`] var classes = [`bg-${this.bg}`, `rounded-${this.rounded}`]
if (this.disabled) classes.push('text-gray-300') if (this.disabled) classes.push('text-gray-300')
else classes.push('text-white') else classes.push('text-white')

View file

@ -0,0 +1,53 @@
<template>
<textarea ref="input" 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)
},
blur() {
if (this.$refs.input && this.$refs.input.blur) {
this.$refs.input.blur()
}
}
},
mounted() {}
}
</script>
<style scoped>
textarea {
border-style: inherit !important;
}
textarea:read-only {
color: #aaa;
background-color: #444;
}
</style>

View file

@ -0,0 +1,41 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
</div>
</template>
<script>
export default {
props: {
value: [String, Number],
label: String,
disabled: Boolean,
rows: {
type: Number,
default: 2
}
},
data() {
return {}
},
computed: {
inputValue: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
blur() {
if (this.$refs.input && this.$refs.input.blur) {
this.$refs.input.blur()
}
}
},
mounted() {}
}
</script>

View file

@ -31,15 +31,15 @@
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div class="flex items-center mb-4 py-4 px-2"> <div class="flex items-center py-4 px-2">
<div class="flex items-center" @click="clearSelected"> <div class="flex items-center" @click="clearSelected">
<span class="material-icons text-2xl text-gray-300">arrow_back</span> <span class="material-icons text-2xl text-gray-300">arrow_back</span>
<p class="pl-2 uppercase text-sm font-semibold text-gray-300 leading-4 pb-px">Back</p> <p class="pl-2 uppercase text-sm font-semibold text-gray-300 leading-4 pb-px">Back</p>
</div> </div>
</div> </div>
<div class="p-2"> <div class="w-full py-2 search-results-container overflow-y-auto overflow-x-hidden">
<p>Selected Podcast Feed</p> <forms-new-podcast-form :podcast-data="selectedPodcast" :podcast-feed-data="selectedPodcastFeed" :processing.sync="processing" />
</div> </div>
</template> </template>

View file

@ -5,21 +5,21 @@ export default function ({ $axios, store }) {
return return
} }
var customHeaders = store.getters['user/getCustomHeaders'] const customHeaders = store.getters['user/getCustomHeaders']
if (customHeaders) { if (customHeaders) {
for (const key in customHeaders) { for (const key in customHeaders) {
config.headers.common[key] = customHeaders[key] config.headers.common[key] = customHeaders[key]
} }
} }
var bearerToken = store.getters['user/getToken'] const bearerToken = store.getters['user/getToken']
if (bearerToken) { if (bearerToken) {
config.headers.common['Authorization'] = `Bearer ${bearerToken}` config.headers.common['Authorization'] = `Bearer ${bearerToken}`
} else { } else {
console.warn('[Axios] No Bearer Token for request') console.warn('[Axios] No Bearer Token for request')
} }
var serverUrl = store.getters['user/getServerAddress'] const serverUrl = store.getters['user/getServerAddress']
if (serverUrl) { if (serverUrl) {
config.url = `${serverUrl}${config.url}` config.url = `${serverUrl}${config.url}`
} }

View file

@ -105,6 +105,43 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}` return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
} }
Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
if (typeof input !== 'string') {
return false
}
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet
const MAX_FILENAME_LEN = 240
var replacement = ''
var illegalRe = /[\/\?<>\\:\*\|"]/g
var controlRe = /[\x00-\x1f\x80-\x9f]/g
var reservedRe = /^\.+$/
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
var windowsTrailingRe = /[\. ]+$/
var lineBreaks = /[\n\r]/g
var sanitized = input
.replace(':', colonReplacement) // Replace first occurrence of a colon
.replace(illegalRe, replacement)
.replace(controlRe, replacement)
.replace(reservedRe, replacement)
.replace(lineBreaks, replacement)
.replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement)
if (sanitized.length > MAX_FILENAME_LEN) {
var lenToRemove = sanitized.length - MAX_FILENAME_LEN
var ext = Path.extname(sanitized)
var basename = Path.basename(sanitized, ext)
basename = basename.slice(0, basename.length - lenToRemove)
sanitized = basename + ext
}
return sanitized
}
function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) { function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) {
const isDOMElement = (element) => { const isDOMElement = (element) => {
return element instanceof Element || element instanceof HTMLDocument return element instanceof Element || element instanceof HTMLDocument