diff --git a/client/components/cards/BookUploadCard.vue b/client/components/cards/BookUploadCard.vue
new file mode 100644
index 00000000..08d66e2f
--- /dev/null
+++ b/client/components/cards/BookUploadCard.vue
@@ -0,0 +1,120 @@
+
+
+
+
#{{ book.index }}
+
+
+
+ close
+
+
+
+
+
{{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Directory (auto)
+
+
+
+
+
+
+
+
+
+
+
Successfully Uploaded!
+
+
+
Failed to upload
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/tables/UploadedFilesTable.vue b/client/components/tables/UploadedFilesTable.vue
new file mode 100644
index 00000000..3f5b52c5
--- /dev/null
+++ b/client/components/tables/UploadedFilesTable.vue
@@ -0,0 +1,60 @@
+
+
+
+
{{ title }}
+ {{ files.length }}
+
+
+ expand_more
+
+
+
+
+
+
+
Filename
+
Size
+
Type
+
+
+
+
+ {{ file.name }}
+
+
+ {{ $bytesPretty(file.size) }}
+
+
+ {{ file.filetype }}
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/widgets/Alert.vue b/client/components/widgets/Alert.vue
new file mode 100644
index 00000000..17f3b66c
--- /dev/null
+++ b/client/components/widgets/Alert.vue
@@ -0,0 +1,33 @@
+
+
+
+ {{ icon }}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/mixins/uploadHelpers.js b/client/mixins/uploadHelpers.js
new file mode 100644
index 00000000..4223872f
--- /dev/null
+++ b/client/mixins/uploadHelpers.js
@@ -0,0 +1,230 @@
+import Path from 'path'
+
+export default {
+ data() {
+ return {
+ uploadHelpers: {
+ getBooksFromDrop: this.getBooksFromDataTransferItems,
+ getBooksFromPicker: this.getBooksFromFileList
+ }
+ }
+ },
+ methods: {
+ checkFileType(filename) {
+ var ext = Path.extname(filename)
+ if (!ext) return false
+ if (ext.startsWith('.')) ext = ext.slice(1)
+ ext = ext.toLowerCase()
+
+ for (const filetype in this.$constants.SupportedFileTypes) {
+ if (this.$constants.SupportedFileTypes[filetype].includes(ext)) {
+ return filetype
+ }
+ }
+ return false
+ },
+ filterAudiobookFiles(files) {
+ var validBookFiles = []
+ var validOtherFiles = []
+ var ignoredFiles = []
+ files.forEach((file) => {
+ var filetype = this.checkFileType(file.name)
+ if (!filetype) ignoredFiles.push(file)
+ else {
+ file.filetype = filetype
+ if (filetype === 'audio' || filetype === 'ebook') validBookFiles.push(file)
+ else validOtherFiles.push(file)
+ }
+ })
+
+ return {
+ bookFiles: validBookFiles,
+ otherFiles: validOtherFiles,
+ ignoredFiles
+ }
+ },
+ audiobookFromItems(items) {
+ var { bookFiles, otherFiles, ignoredFiles } = this.filterAudiobookFiles(items)
+ if (!bookFiles.length) {
+ ignoredFiles = ignoredFiles.concat(otherFiles)
+ otherFiles = []
+ }
+ return [
+ {
+ bookFiles,
+ otherFiles,
+ ignoredFiles
+ }
+ ]
+ },
+ traverseForAudiobook(folder, depth = 1) {
+ if (folder.items.some((f) => f.isDirectory)) {
+ var audiobooks = []
+ folder.items.forEach((file) => {
+ if (file.isDirectory) {
+ var audiobookResults = this.traverseForAudiobook(file, ++depth)
+ audiobooks = audiobooks.concat(audiobookResults)
+ }
+ })
+ return audiobooks
+ } else {
+ return this.audiobookFromItems(folder.items)
+ }
+ },
+ fileTreeToAudiobooks(filetree) {
+ // Has directores - Is Multi Book Drop
+ if (filetree.some((f) => f.isDirectory)) {
+ var ignoredFilesInRoot = filetree.filter((f) => !f.isDirectory)
+ if (ignoredFilesInRoot.length) filetree = filetree.filter((f) => f.isDirectory)
+
+ var audiobookResults = this.traverseForAudiobook({ items: filetree })
+ return {
+ audiobooks: audiobookResults,
+ ignoredFiles: ignoredFilesInRoot
+ }
+ } else {
+ // Single Book drop
+ return {
+ audiobooks: this.audiobookFromItems(filetree),
+ ignoredFiles: []
+ }
+ }
+ },
+ getFilesDropped(dataTransferItems) {
+ var treemap = {
+ path: '/',
+ items: []
+ }
+ function traverseFileTreePromise(item, currtreemap) {
+ return new Promise((resolve) => {
+ if (item.isFile) {
+ item.file((file) => {
+ file.filepath = currtreemap.path + file.name //save full path
+ currtreemap.items.push(file)
+ resolve(file)
+ })
+ } else if (item.isDirectory) {
+ let dirReader = item.createReader()
+ currtreemap.items.push({
+ isDirectory: true,
+ dirname: item.name,
+ path: currtreemap.path + item.name + '/',
+ items: []
+ })
+ var newtreemap = currtreemap.items[currtreemap.items.length - 1]
+ dirReader.readEntries((entries) => {
+ let entriesPromises = []
+ for (let entr of entries) entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
+ resolve(Promise.all(entriesPromises))
+ })
+ }
+ })
+ }
+
+ return new Promise((resolve, reject) => {
+ let entriesPromises = []
+ for (let it of dataTransferItems) {
+ var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap)
+ entriesPromises.push(filetree)
+ }
+ Promise.all(entriesPromises).then(() => {
+ resolve(treemap.items)
+ })
+ })
+ },
+ cleanBook(book, index) {
+ var audiobook = {
+ index,
+ title: '',
+ author: '',
+ series: '',
+ ...book
+ }
+ var firstBookFile = book.bookFiles[0]
+ if (!firstBookFile.filepath) return audiobook // No path
+
+ var firstBookPath = Path.dirname(firstBookFile.filepath)
+
+ var dirs = firstBookPath.split('/').filter(d => !!d && d !== '.')
+ if (dirs.length) {
+ audiobook.title = dirs.pop()
+ if (dirs.length > 1) {
+ audiobook.series = dirs.pop()
+ }
+ if (dirs.length) {
+ audiobook.author = dirs.pop()
+ }
+ }
+ return audiobook
+ },
+ async getBooksFromDataTransferItems(items) {
+ var files = await this.getFilesDropped(items)
+ if (!files || !files.length) return { error: 'No files found ' }
+ var audiobooksData = this.fileTreeToAudiobooks(files)
+ if (!audiobooksData.audiobooks.length && !audiobooksData.ignoredFiles.length) {
+ return { error: 'Invalid file drop' }
+ }
+ var ignoredFiles = audiobooksData.ignoredFiles
+ var index = 1
+ var books = audiobooksData.audiobooks.filter((ab) => {
+ if (!ab.bookFiles.length) {
+ if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles)
+ if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
+ }
+ return ab.bookFiles.length
+ }).map(ab => this.cleanBook(ab, index++))
+ return {
+ books,
+ invalidBooks,
+ ignoredFiles
+ }
+ },
+ getBooksFromFileList(filelist) {
+ var ignoredFiles = []
+ var otherFiles = []
+
+ var bookMap = {}
+
+ filelist.forEach((file) => {
+ var filetype = this.checkFileType(file.name)
+ if (!filetype) ignoredFiles.push(file)
+ else {
+ file.filetype = filetype
+ if (file.webkitRelativePath) file.filepath = file.webkitRelativePath
+
+ if (filetype === 'audio' || filetype === 'ebook') {
+ var dir = file.filepath ? Path.dirname(file.filepath) : ''
+ if (!bookMap[dir]) {
+ bookMap[dir] = {
+ path: dir,
+ ignoredFiles: [],
+ bookFiles: [],
+ otherFiles: []
+ }
+ }
+ bookMap[dir].bookFiles.push(file)
+ } else {
+ otherFiles.push(file)
+ }
+ }
+ })
+
+ otherFiles.forEach((file) => {
+ var dir = Path.dirname(file.filepath)
+ var findBook = Object.values(bookMap).find(b => dir.startsWith(b.path))
+ if (findBook) {
+ bookMap[dir].otherFiles.push(file)
+ } else {
+ ignoredFiles.push(file)
+ }
+ })
+
+ var index = 1
+ var books = Object.values(bookMap).map(ab => this.cleanBook(ab, index++))
+ return {
+ books,
+ ignoredFiles: ignoredFiles
+ }
+ },
+ }
+}
\ No newline at end of file
diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue
index dca805af..c97ef200 100644
--- a/client/pages/upload/index.vue
+++ b/client/pages/upload/index.vue
@@ -1,161 +1,99 @@
-
-
-
Audiobook Uploader
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
{{ error }}
+
+
+
+
+
{{ isDragging ? 'Drop files' : "Drag n' drop files or folders" }}