diff --git a/frontend/src/components/CameraFileSource.vue b/frontend/src/components/CameraFileSource.vue new file mode 100644 index 0000000..5a93a89 --- /dev/null +++ b/frontend/src/components/CameraFileSource.vue @@ -0,0 +1,76 @@ +<template> + <div class="d-inline-block" v-if="show_camera"> + <label for="pictures"> + <slot></slot> + </label> + <input type="file" accept="image/*" multiple capture="camera" id="pictures" @change="loadFiles" class="d-none"> + </div> +</template> + +<style scoped> +</style> + +<script> +import * as BIcons from "bootstrap-icons-vue"; + +export default { + name: "CameraFileSource", + components: { + ...BIcons + }, + emits: ["input"], + data() { + return { + show_camera: false + } + }, + methods: { + loadFiles() { + const files = document.getElementById("pictures").files; + const jobs = [...files].map((file) => { + return new Promise((resolve, reject) => { + var reader = new FileReader(); + reader.onload = () => { + const buffer = reader.result; + if (!(buffer instanceof ArrayBuffer)) { + console.log(buffer) + reject("Not an ArrayBuffer"); + } + const data = new Uint8Array(buffer); + const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), ""); + var base64 = btoa( + data.reduce((a, b) => a + String.fromCharCode(b), '') + ); + resolve({ + name: file.name, + size: file.size, + mime_type: file.type, + data: base64, + hash: hash, + }); + }; + reader.onerror = (e) => { + reject(e); + }; + reader.readAsArrayBuffer(file) + }) + }); + Promise.all(jobs).then((files) => { + this.$emit("input", files) + }) + } + }, + mounted() { + //detect media api + if ('capture' in document.createElement('input')) { + console.log('capture supported.'); + this.show_camera = true; + } + + if (navigator.getUserMedia) { + console.log('getUserMedia supported.'); + this.show_camera = true; + } + } +} +</script> \ No newline at end of file diff --git a/frontend/src/components/CombinedFileField.vue b/frontend/src/components/CombinedFileField.vue index ef678f7..06ee839 100644 --- a/frontend/src/components/CombinedFileField.vue +++ b/frontend/src/components/CombinedFileField.vue @@ -1,33 +1,33 @@ <template> - <div class="input-group"> + <drag-drop-file-source @input="addFiles"> + <h3 v-if="create">Create New</h3> + <h3 v-else>Update</h3> <ul> - <li v-for="file in files" :key="file.id"> + <li v-for="file in whithout_images(files)" :key="file.id"> + {{ file.name }} + </li> + <li v-for="file in whithout_images(item_files)" :key="file.id"> {{ file.name }} </li> </ul> - <ul> - <img v-for="file in files" :key="file.id" :src="file.name" :alt="file.name" class="img-thumbnail"> - </ul> - <h4>Files</h4> - <div class="input-group"> - <input type="file" class="form-control" multiple id="files"> - <button class="btn btn-outline-secondary" type="button"> - <b-icon-trash></b-icon-trash> - </button> - <button class="btn btn-outline-secondary" type="button" @click="uploadFiles"> - <b-icon-upload></b-icon-upload> - </button> + <hr> + <div> + <img v-for="file in only_images(files)" :key="file.id" :src="file.name" :alt="file.name" + class="img-thumbnail" :title="file.mime_type"> + <img v-for="file in whithout_images(item_files)" :key="file.id" :alt="file.name" + :src="'data:' + file.mime_type + ';base64,' + file.data" class="img-thumbnail border-info"> + <fs-file-source @input="addFiles"> + <div class="img-thumbnail btn btn-outline-primary"> + <b-icon-upload></b-icon-upload> + </div> + </fs-file-source> + <camera-file-source @input="addFiles"> + <div class="img-thumbnail btn btn-outline-primary"> + <b-icon-camera></b-icon-camera> + </div> + </camera-file-source> </div> - <div class="input-group" v-if="show_camera"> - <input type="file" class="form-control" accept="image/*" multiple capture="camera" id="photos"> - <button class="btn btn-outline-secondary" type="button"> - <b-icon-trash></b-icon-trash> - </button> - <button class="btn btn-outline-secondary" type="button" @click="uploadPhoto"> - <b-icon-upload></b-icon-upload> - </button> - </div> - </div> + </drag-drop-file-source> </template> <style scoped> @@ -36,108 +36,82 @@ height: 54px; object-fit: cover; } + +.img-thumbnail svg { + width: 100%; + height: 100%; +} </style> <script> import * as BIcons from "bootstrap-icons-vue"; import {mapActions, mapState} from "vuex"; +import DragDropFileSource from "@/components/DragDropFileSource.vue"; +import FsFileSource from "@/components/FsFileSource.vue"; +import CameraFileSource from "@/components/CameraFileSource.vue"; export default { name: "CombinedFileField", - data() { - return { - show_camera: false - } - }, components: { - ...BIcons + ...BIcons, + DragDropFileSource, + CameraFileSource, + FsFileSource }, props: { - value: { + item_files: { type: Array, required: true }, + item_id: { + type: Number + }, + create: { + type: Boolean, + default: false + } }, - model: { - prop: "value", - event: "input" - }, + emits: ["change"], computed: { ...mapState(["files"]), }, methods: { ...mapActions(["fetchFiles", "pushFile"]), - async uploadFiles() { - const files = document.getElementById("files").files; - for (let i = 0; i < files.length; i++) { - var reader = new FileReader(); - const file = files[i]; - const buffer = await new Promise((resolve, reject) => { - reader.onload = (e) => { - resolve(e.target.result); - }; - reader.onerror = (e) => { - reject(e); - }; - reader.readAsArrayBuffer(file) - }); - const data = new Uint8Array(buffer); - const hash = nacl.crypto_hash(data); - var base64 = btoa( - data.reduce((a, b) => a + String.fromCharCode(b), '') - ); + async uploadFiles(files) { + const jobs = files.map(async file => { await this.pushFile({ - file: { - mime_type: file.type, - data: base64, - }, - item_id: 1 + file: file, + item_id: this.item_id }); - await this.fetchFiles(); - } + }); + const responses = await Promise.all(jobs); + console.log(responses); + return responses; }, - async uploadPhoto() { - const files = document.getElementById("photos").files; - for (let i = 0; i < files.length; i++) { - var reader = new FileReader(); - const file = files[i]; - const buffer = await new Promise((resolve, reject) => { - reader.onload = (e) => { - resolve(e.target.result); - }; - reader.onerror = (e) => { - reject(e); - }; - reader.readAsArrayBuffer(file) - }); - const data = new Uint8Array(buffer); - const hash = nacl.crypto_hash(data); - var base64 = btoa( - data.reduce((a, b) => a + String.fromCharCode(b), '') - ); - await this.pushFile({ - file: { - mime_type: file.type, - data: base64, - }, - item_id: 1 - }); - await this.fetchFiles(); + addFiles(files) { + const newfiles = files.filter(file => !this.item_files.find(f => f.hash === file.hash)); + if (newfiles.length === 0) { + console.log("no new files"); + return; } + if (!this.create) { + this.uploadFiles(newfiles).then((uploaded) => { + console.log(uploaded); + }) + } + this.$emit("change", [...this.item_files, ...newfiles]); + console.log(this.item_files); + + }, + only_images(files) { + return files.filter(file => file.mime_type.startsWith("image/")); + }, + whithout_images(files) { + return files.filter(file => !file.mime_type.startsWith("image/")); }, }, mounted() { this.fetchFiles(); - //detect media api - if ('capture' in document.createElement('input')) { - console.log('capture supported.'); - this.show_camera = true; - } - - if (navigator.getUserMedia) { - console.log('getUserMedia supported.'); - this.show_camera = true; - } } } </script> \ No newline at end of file diff --git a/frontend/src/components/DragDropFileSource.vue b/frontend/src/components/DragDropFileSource.vue new file mode 100644 index 0000000..6646ef5 --- /dev/null +++ b/frontend/src/components/DragDropFileSource.vue @@ -0,0 +1,88 @@ +<template> + <div @dragenter.prevent="dragenter" @dragover.prevent="dragover" @dragleave.prevent="dragleave" + @drop.prevent="drop" ref="dropzone"> + <slot></slot> + </div> +</template> + +<style scoped> +.dragover { + opacity: 0.5; +} +</style> + +<script> +import * as BIcons from "bootstrap-icons-vue"; + +export default { + name: "DragDropFileSource", + components: { + ...BIcons + }, + emits: ["input"], + methods: { + dragenter(e) { + if (e.dataTransfer.types.includes("Files")) { + e.preventDefault() + e.stopPropagation() + this.$refs.dropzone.classList.add("dragover") + }else{ + console.log(e.dataTransfer.types) //TODO text/uri-list? + } + }, + dragover(e) { + if (e.dataTransfer.types.includes("Files")) { + e.preventDefault() + e.stopPropagation() + this.$refs.dropzone.classList.add("dragover") + } + }, + dragleave(e) { + if (e.dataTransfer.types.includes("Files")) { + e.preventDefault() + e.stopPropagation() + this.$refs.dropzone.classList.remove("dragover") + } + }, + drop(e) { + if (e.dataTransfer.types.includes("Files")) { + e.preventDefault() + e.stopPropagation() + this.$refs.dropzone.classList.remove("dragover") + const jobs = [...e.dataTransfer.files].map((file) => { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + reader.readAsArrayBuffer(file) + reader.onloadend = () => { + const buffer = reader.result; + if (!(buffer instanceof ArrayBuffer)) { + console.log(buffer) + reject("Not an ArrayBuffer"); + } + const data = new Uint8Array(buffer); + const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), ""); + var base64 = btoa( + data.reduce((a, b) => a + String.fromCharCode(b), '') + ); + resolve({ + name: file.name, + size: file.size, + mime_type: file.type, + data: base64, + hash: hash, + }); + } + reader.onerror = (e) => { + reject(e) + } + }) + }) + Promise.all(jobs).then((files) => { + this.$emit("input", files) + console.log("drop", files) + }) + } + } + } +} +</script> \ No newline at end of file diff --git a/frontend/src/components/FsFileSource.vue b/frontend/src/components/FsFileSource.vue new file mode 100644 index 0000000..29bff9a --- /dev/null +++ b/frontend/src/components/FsFileSource.vue @@ -0,0 +1,59 @@ +<template> + <div class="d-inline-block"> + <label for="files"> + <slot></slot> + </label> + <input type="file" multiple id="files" @change="loadFiles" class="d-none"> + </div> +</template> + +<style scoped> +</style> + +<script> +import * as BIcons from "bootstrap-icons-vue"; + +export default { + name: "FsFileSource", + components: { + ...BIcons + }, + emits: ["input"], + methods: { + loadFiles() { + const files = document.getElementById("files").files; + const jobs = [...files].map((file) => { + return new Promise((resolve, reject) => { + var reader = new FileReader(); + reader.onload = () => { + const buffer = reader.result; + if (!(buffer instanceof ArrayBuffer)) { + console.log(buffer) + reject("Not an ArrayBuffer"); + } + const data = new Uint8Array(buffer); + const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), ""); + var base64 = btoa( + data.reduce((a, b) => a + String.fromCharCode(b), '') + ); + resolve({ + name: file.name, + size: file.size, + mime_type: file.type, + data: base64, + hash: hash, + }); + }; + reader.onerror = (e) => { + reject(e); + }; + reader.readAsArrayBuffer(file) + }) + }); + Promise.all(jobs).then((files) => { + this.$emit("input", files) + }) + } + }, +} +</script> \ No newline at end of file diff --git a/frontend/src/store.js b/frontend/src/store.js index 36d06dc..9eb157f 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -253,7 +253,7 @@ export default createStore({ async pushFile({state, dispatch, getters}, {item_id, file}) { const servers = await dispatch('getHomeServers') const data = await servers.post(getters.signAuth, '/api/item_files/'+item_id+'/', file) - if (data.mime_type) { + if (data.hash) { state.files.push(data) return data } diff --git a/frontend/src/views/InventoryDetail.vue b/frontend/src/views/InventoryDetail.vue index c0d4f8b..40dcd98 100644 --- a/frontend/src/views/InventoryDetail.vue +++ b/frontend/src/views/InventoryDetail.vue @@ -36,10 +36,10 @@ </div> <!-- actions --> <div class="card"> - <a class="btn btn-primary" :href="'/inventory/' + id + '/edit'"> + <button class="btn btn-primary" @click="$router.push('/inventory/' + id + '/edit')"> <b-icon-pencil-square></b-icon-pencil-square> Edit - </a> + </button> <button type="submit" class="btn btn-danger" @click="deleteInventoryItem(item).then(() => $router.push('/inventory'))"> <b-icon-trash></b-icon-trash> diff --git a/frontend/src/views/InventoryEdit.vue b/frontend/src/views/InventoryEdit.vue index e1ef54c..4e0f409 100644 --- a/frontend/src/views/InventoryEdit.vue +++ b/frontend/src/views/InventoryEdit.vue @@ -6,6 +6,13 @@ <div class="card"> <div class="card-header">Edit Item</div> <div class="card-body"> + <div class="mb-3"> + <ul> + <li v-for="file in item.files" :key="file.name"> + {{ file.name }} ({{ file.hash }}) + </li> + </ul> + </div> <div class="mb-3"> <label for="name" class="form-label">Name</label> <input type="text" class="form-control" id="name" name="name" @@ -31,7 +38,7 @@ </div> <div class="mb-3"> <label for="image" class="form-label">Image</label> - <combined-file-field :value="item.files"></combined-file-field> + <combined-file-field :item_files="item.files" :item_id="item.id" @change="addFiles"></combined-file-field> </div> </div> <div class="card"> @@ -78,12 +85,16 @@ export default { description: "", owned_quantity: 0, image: "", + files: [], ...this.inventory_items.find(item => item.id === parseInt(this.id)) } } }, methods: { - ...mapActions(["fetchInventoryItems", "updateInventoryItem"]) + ...mapActions(["fetchInventoryItems", "updateInventoryItem"]), + addFiles(files) { + this.inventory_items.find(item => item.id === parseInt(this.id)).files = files + }, }, async mounted() { await this.fetchInventoryItems() diff --git a/frontend/src/views/InventoryNew.vue b/frontend/src/views/InventoryNew.vue index 0b78fc0..e23d63e 100644 --- a/frontend/src/views/InventoryNew.vue +++ b/frontend/src/views/InventoryNew.vue @@ -6,6 +6,13 @@ <div class="card"> <div class="card-header">Create New Item</div> <div class="card-body"> + <div class="mb-3"> + <ul> + <li v-for="file in item.files" :key="file.name"> + {{ file.name }} ({{ file.hash }}) + </li> + </ul> + </div> <div class="mb-3"> <label for="name" class="form-label">Name</label> <input type="text" class="form-control" id="name" name="name" @@ -31,7 +38,8 @@ </div> <div class="mb-3"> <label for="image" class="form-label">Image</label> - <combined-file-field :value="item.files"></combined-file-field> + <combined-file-field :item_files="item.files" create + @change="item.files = $event"></combined-file-field> </div> </div> <div class="card"> @@ -71,7 +79,8 @@ export default { owned_quantity: 0, image: "", tags: [], - properties: [] + properties: [], + files: [] } } },