Add:Delete library files, condense item options in more menu #1439

This commit is contained in:
advplyr 2023-04-13 18:03:39 -05:00
parent 24ef105732
commit 5a21e63d0b
7 changed files with 254 additions and 60 deletions

View File

@ -18,25 +18,10 @@
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
<th class="text-left px-4 w-24">{{ $strings.LabelType }}</th>
<th v-if="userCanDownload && !isMissing" class="text-center w-20">{{ $strings.LabelDownload }}</th>
<th v-if="userCanDelete || userCanDownload" class="text-center w-16"></th>
</tr>
<template v-for="file in files">
<tr :key="file.path">
<td class="px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.metadata.size) }}
</td>
<td class="text-xs">
<div class="flex items-center">
<p>{{ file.fileType }}</p>
</div>
</td>
<td v-if="userCanDownload && !isMissing" class="text-center">
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
<tables-library-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" />
</template>
</table>
</div>
@ -68,6 +53,9 @@ export default {
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
}

View File

@ -0,0 +1,105 @@
<template>
<tr>
<td class="px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.metadata.size) }}
</td>
<td class="text-xs">
<div class="flex items-center">
<p>{{ file.fileType }}</p>
</div>
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
</td>
</tr>
</template>
<script>
export default {
props: {
libraryItemId: String,
showFullPath: Boolean,
file: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
downloadUrl() {
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.file.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
},
contextMenuItems() {
const items = []
if (this.userCanDownload) {
items.push({
text: this.$strings.LabelDownload,
action: 'download'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
return items
}
},
methods: {
contextMenuAction(action) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
this.downloadLibraryFile()
}
},
deleteLibraryFile() {
const payload = {
message: 'This will delete the file from your file system. Are you sure?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
.then(() => {
this.$toast.success('File deleted')
})
.catch((error) => {
console.error('Failed to delete file', error)
this.$toast.error('Failed to delete file')
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
const a = document.createElement('a')
a.style.display = 'none'
a.href = this.downloadUrl
a.download = this.file.metadata.filename
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
}
},
mounted() {}
}
</script>

View File

@ -1,11 +1,13 @@
<template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons" :class="iconClass">more_vert</span>
</button>
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu">
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons" :class="iconClass">more_vert</span>
</button>
</slot>
<transition name="menu">
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 w-48 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm">
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm" :style="{ width: menuWidth }">
<template v-for="(item, index) in items">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<p>{{ item.text }}</p>
@ -27,6 +29,10 @@ export default {
iconClass: {
type: String,
default: ''
},
menuWidth: {
type: String,
default: '192px'
}
},
data() {

View File

@ -201,34 +201,18 @@
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
</ui-tooltip>
<ui-tooltip v-if="showCollectionsButton" :text="$strings.LabelCollections" direction="top">
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
</ui-tooltip>
<ui-tooltip v-if="!isPodcast && tracks.length" :text="$strings.LabelYourPlaylists" direction="top">
<ui-icon-btn icon="playlist_add" class="mx-0.5" outlined @click="playlistsClick" />
</ui-tooltip>
<!-- Only admin or root user can download new episodes -->
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip>
<ui-tooltip v-if="bookmarks.length" :text="$strings.LabelYourBookmarks" direction="top">
<ui-icon-btn :icon="bookmarks.length ? 'bookmarks' : 'bookmark_border'" class="mx-0.5" @click="clickBookmarksBtn" />
</ui-tooltip>
<!-- RSS feed -->
<ui-tooltip v-if="showRssFeedBtn" :text="$strings.LabelOpenRSSFeed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeed ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
</ui-tooltip>
<!-- Download -->
<ui-tooltip v-if="userCanDownload" :text="$strings.LabelDownload" direction="top">
<a :href="downloadPath" class="h-9 w-9 mx-0.5 rounded-md flex items-center justify-center relative bg-primary border border-gray-600">
<span class="material-icons-outlined" :style="{ fontSize: '1.4rem' }">download</span>
</a>
</ui-tooltip>
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" menu-width="148px" @action="contextMenuAction">
<template #default="{ showMenu, clickShowMenu, disabled }">
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons">more_vert</span>
</button>
</template>
</ui-context-menu-dropdown>
</div>
<div class="my-4 max-w-2xl">
@ -294,7 +278,7 @@ export default {
userToken() {
return this.$store.getters['user/getToken']
},
downloadPath() {
downloadUrl() {
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
},
dateFormat() {
@ -309,9 +293,6 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
isFile() {
return this.libraryItem.isFile
},
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
@ -321,6 +302,9 @@ export default {
isDeveloperMode() {
return this.$store.state.developerMode
},
isFile() {
return this.libraryItem.isFile
},
isBook() {
return this.libraryItem.mediaType === 'book'
},
@ -539,12 +523,56 @@ export default {
},
showCollectionsButton() {
return this.isBook && this.userCanUpdate
},
contextMenuItems() {
const items = []
if (this.showCollectionsButton) {
items.push({
text: this.$strings.LabelCollections,
action: 'collections'
})
}
if (!this.isPodcast && this.tracks.length) {
items.push({
text: this.$strings.LabelYourPlaylists,
action: 'playlists'
})
}
if (this.bookmarks.length) {
items.push({
text: this.$strings.LabelYourBookmarks,
action: 'bookmarks'
})
}
if (this.showRssFeedBtn) {
items.push({
text: this.$strings.LabelOpenRSSFeed,
action: 'rss-feeds'
})
}
if (this.userCanDownload) {
items.push({
text: this.$strings.LabelDownload,
action: 'download'
})
}
// if (this.userCanDelete) {
// items.push({
// text: this.$strings.ButtonDelete,
// action: 'delete'
// })
// }
return items
}
},
methods: {
clickBookmarksBtn() {
this.showBookmarksModal = true
},
selectBookmark(bookmark) {
if (!bookmark) return
if (this.isStreaming) {
@ -720,14 +748,6 @@ export default {
})
}
},
collectionsClick() {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShowCollectionsModal', true)
},
playlistsClick() {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
this.$store.commit('globals/setShowPlaylistsModal', true)
},
clickRSSFeed() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.libraryItemId,
@ -785,6 +805,54 @@ export default {
}
this.$store.commit('addItemToQueue', queueItem)
}
},
downloadLibraryItem() {
const a = document.createElement('a')
a.style.display = 'none'
a.href = this.downloadUrl
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
},
deleteLibraryItem() {
const payload = {
message: 'This will delete the library item files from your file system. Are you sure?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$delete(`/api/items/${this.libraryItemId}?hard=1`)
.then(() => {
this.$toast.success('Item deleted')
this.$router.replace(`/library/${this.libraryId}/bookshelf`)
})
.catch((error) => {
console.error('Failed to delete item', error)
this.$toast.error('Failed to delete item')
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
contextMenuAction(action) {
if (action === 'collections') {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShowCollectionsModal', true)
} else if (action === 'playlists') {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
this.$store.commit('globals/setShowPlaylistsModal', true)
} else if (action === 'bookmarks') {
this.showBookmarksModal = true
} else if (action === 'rss-feeds') {
this.clickRSSFeed()
} else if (action === 'download') {
this.downloadLibraryItem()
} else if (action === 'delete') {
this.deleteLibraryItem()
}
}
},
mounted() {

View File

@ -482,6 +482,28 @@ class LibraryItemController {
res.json(toneData)
}
async deleteLibraryFile(req, res) {
const libraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.ino)
if (!libraryFile) {
Logger.error(`[LibraryItemController] Unable to delete library file. Not found. "${req.params.ino}"`)
return res.sendStatus(404)
}
await fs.remove(libraryFile.metadata.path)
req.libraryItem.removeLibraryFile(req.params.ino)
if (req.libraryItem.media.removeFileWithInode(req.params.ino)) {
// If book has no more media files then mark it as missing
if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) {
req.libraryItem.setMissing()
}
}
req.libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
res.sendStatus(200)
}
middleware(req, res, next) {
const item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)

View File

@ -166,7 +166,11 @@ class Podcast {
}
removeFileWithInode(inode) {
this.episodes = this.episodes.filter(ep => ep.audioFile.ino !== inode)
const hasEpisode = this.episodes.some(ep => ep.audioFile.ino === inode)
if (hasEpisode) {
this.episodes = this.episodes.filter(ep => ep.audioFile.ino !== inode)
}
return hasEpisode
}
findFileWithInode(inode) {

View File

@ -112,6 +112,7 @@ class ApiRouter {
this.router.get('/items/:id/tone-object', LibraryItemController.middleware.bind(this), LibraryItemController.getToneMetadataObject.bind(this))
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
this.router.post('/items/:id/tone-scan/:index?', LibraryItemController.middleware.bind(this), LibraryItemController.toneScan.bind(this))
this.router.delete('/items/:id/file/:ino', LibraryItemController.middleware.bind(this), LibraryItemController.deleteLibraryFile.bind(this))
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))