add item images
This commit is contained in:
parent
0af1f40b07
commit
448c166507
18 changed files with 826 additions and 15 deletions
|
|
@ -1,2 +1,4 @@
|
||||||
|
|
||||||
ALLOWED_HOSTS="localhost,127.0.0.1"
|
ALLOWED_HOSTS="localhost,127.0.0.1"
|
||||||
|
|
||||||
|
SERVE_X_ACCEL_REDIRECT=True
|
||||||
|
|
@ -86,6 +86,8 @@ CORS_ALLOW_ALL_ORIGINS = True
|
||||||
USE_X_FORWARDED_HOST = True
|
USE_X_FORWARDED_HOST = True
|
||||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||||
|
|
||||||
|
SERVE_X_ACCEL_REDIRECT = os.environ.get('SERVE_X_ACCEL_REDIRECT', 'False').lower() == 'true'
|
||||||
|
|
||||||
ROOT_URLCONF = 'backend.urls'
|
ROOT_URLCONF = 'backend.urls'
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from authentication.signature_auth import SignatureAuthentication
|
from authentication.signature_auth import SignatureAuthentication
|
||||||
|
from backend import settings
|
||||||
from files.models import File
|
from files.models import File
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -19,12 +20,21 @@ def media_urls(request, hash_path):
|
||||||
file = File.objects.filter(connected_items__owner__in=request.user.friends_or_self()).distinct().get(
|
file = File.objects.filter(connected_items__owner__in=request.user.friends_or_self()).distinct().get(
|
||||||
file=hash_path)
|
file=hash_path)
|
||||||
|
|
||||||
return HttpResponse(status=status.HTTP_200_OK,
|
if settings.SERVE_X_ACCEL_REDIRECT:
|
||||||
content_type=file.mime_type,
|
return HttpResponse(status=status.HTTP_200_OK,
|
||||||
headers={
|
content_type=file.mime_type,
|
||||||
'X-Accel-Redirect': f'/redirect_media/{hash_path}',
|
headers={
|
||||||
'Access-Control-Allow-Origin': '*',
|
'X-Accel-Redirect': f'/redirect_media/{hash_path}',
|
||||||
}) # TODO Expires and Cache-Control
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
}) # TODO Expires and Cache-Control
|
||||||
|
else:
|
||||||
|
return HttpResponse(status=status.HTTP_200_OK,
|
||||||
|
content_type=file.mime_type,
|
||||||
|
headers={
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
content=open(file.file.path, 'rb').read())
|
||||||
|
|
||||||
|
|
||||||
except File.DoesNotExist:
|
except File.DoesNotExist:
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
|
|
||||||
|
SERVE_X_ACCEL_REDIRECT=False
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY='e*lm&*!j0_stqaiod$1zob(vs@aq6+n-i$1%!rek)_v9n^ue$3'
|
SECRET_KEY='e*lm&*!j0_stqaiod$1zob(vs@aq6+n-i$1%!rek)_v9n^ue$3'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,16 @@ http {
|
||||||
proxy_pass http://backend;
|
proxy_pass http://backend;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /media {
|
||||||
|
proxy_set_header Host $host:$server_port;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host:$server_port;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_pass http://backend;
|
||||||
|
}
|
||||||
|
|
||||||
location /docs {
|
location /docs {
|
||||||
proxy_pass http://backend/docs;
|
proxy_pass http://backend/docs;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
|
|
||||||
|
SERVE_X_ACCEL_REDIRECT=True
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY='7ccxjje%q@@0*z+r&-$fy3(rj9n)%$!sk-k++-&rb=_u(wpjbe'
|
SECRET_KEY='7ccxjje%q@@0*z+r&-$fy3(rj9n)%$!sk-k++-&rb=_u(wpjbe'
|
||||||
|
|
||||||
|
|
|
||||||
53
frontend/src/components/AuthenticatedImage.vue
Normal file
53
frontend/src/components/AuthenticatedImage.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<template>
|
||||||
|
<img :src="image_data" :title="owner + ':' + src"/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
img {
|
||||||
|
width: 190px;
|
||||||
|
height: 107px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {mapActions, mapGetters} from "vuex";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "AuthenticatedImage",
|
||||||
|
props: {
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
image_data: "",
|
||||||
|
servers: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters(["signAuth"])
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(["getFriendServers"])
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
try {
|
||||||
|
this.servers = await this.getFriendServers({username: this.owner});
|
||||||
|
const response = await this.servers.getRaw(this.signAuth, this.src);
|
||||||
|
const mime_type = response.headers.get("content-type");
|
||||||
|
const base64 = btoa(new Uint8Array(await response.arrayBuffer())
|
||||||
|
.reduce((data, byte) => data + String.fromCharCode(byte), ""));
|
||||||
|
this.image_data = "data:" + mime_type + ";base64," + base64;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
76
frontend/src/components/CameraFileSource.vue
Normal file
76
frontend/src/components/CameraFileSource.vue
Normal file
|
|
@ -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>
|
||||||
193
frontend/src/components/CombinedFileField.vue
Normal file
193
frontend/src/components/CombinedFileField.vue
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
<template>
|
||||||
|
<drag-drop-file-source @input="addFiles">
|
||||||
|
<h3 v-if="create">Create New</h3>
|
||||||
|
<h3 v-else>Update</h3>
|
||||||
|
<ul>
|
||||||
|
<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>
|
||||||
|
<hr>
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div class="image-list">
|
||||||
|
<deletable-wrapper v-for="file in only_images(item_files).filter(file => file.owner)" :key="file.id"
|
||||||
|
@delete="deleteFile(file)">
|
||||||
|
<authenticated-image :src="file.name" :owner="file.owner" class="img-thumbnail"/>
|
||||||
|
</deletable-wrapper>
|
||||||
|
<deletable-wrapper v-for="file in only_images(item_files).filter(file => file.data)" :key="file.id"
|
||||||
|
@delete="deleteTempFile(file)">
|
||||||
|
<img :alt="file.name" :src="'data:' + file.mime_type + ';base64,' + file.data"
|
||||||
|
class="img-thumbnail border-info">
|
||||||
|
</deletable-wrapper>
|
||||||
|
<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>
|
||||||
|
<webcam-file-source @input="addFiles">
|
||||||
|
<div class="img-thumbnail btn btn-outline-primary">
|
||||||
|
<b-icon-camera-video></b-icon-camera-video>
|
||||||
|
</div>
|
||||||
|
</webcam-file-source>
|
||||||
|
<label class="img-thumbnail btn btn-outline-primary" for="file-dropdown">
|
||||||
|
<b-icon-plus></b-icon-plus>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" id="file-dropdown" class="invisible-input">
|
||||||
|
<div class="dropdown-menu" v-if="only_images(files).length > 0">
|
||||||
|
<div class="image-list">
|
||||||
|
<span v-for="file in only_images(files)" :key="file.id" @click="addExistingFiles([file])"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
<authenticated-image :src="file.name" :owner="file.owner" class="img-thumbnail"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</drag-drop-file-source>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.img-thumbnail {
|
||||||
|
width: 95px;
|
||||||
|
height: 54px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-thumbnail svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invisible-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-dropdown:checked ~ .dropdown-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu:hover {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-dropdown:checked ~ label {
|
||||||
|
color: #fff;
|
||||||
|
background-color: var(--bs-primary);
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-dropdown:checked ~ label:hover {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
background-color: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
</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";
|
||||||
|
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||||
|
import DeletableWrapper from "@/components/DeletableWrapper.vue";
|
||||||
|
import WebcamFileSource from "@/components/WebcamFileSource.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "CombinedFileField",
|
||||||
|
components: {
|
||||||
|
WebcamFileSource,
|
||||||
|
...BIcons,
|
||||||
|
AuthenticatedImage,
|
||||||
|
DeletableWrapper,
|
||||||
|
DragDropFileSource,
|
||||||
|
CameraFileSource,
|
||||||
|
FsFileSource
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
item_files: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
item_id: {
|
||||||
|
type: Number
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ["change"],
|
||||||
|
computed: {
|
||||||
|
...mapState(["files"]),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(["fetchFiles", "pushFile", "deleteItemFile"]),
|
||||||
|
async uploadFiles(files) {
|
||||||
|
const jobs = files.map(async file => {
|
||||||
|
return await this.pushFile({
|
||||||
|
file: file,
|
||||||
|
item_id: this.item_id
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return await Promise.all(jobs);
|
||||||
|
},
|
||||||
|
addFiles(files) {
|
||||||
|
console.log("add files", files);
|
||||||
|
const new_files = files.filter(file => !this.item_files.find(f => f.hash === file.hash));
|
||||||
|
if (new_files.length === 0) {
|
||||||
|
console.log("no new files");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.create) {
|
||||||
|
this.uploadFiles(new_files).then((uploaded) => {
|
||||||
|
this.$emit("change", [...this.item_files, ...uploaded]);
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.$emit("change", [...this.item_files, ...new_files]);
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
addExistingFiles(files) {
|
||||||
|
console.log("add existing files", files);
|
||||||
|
const new_files = files.filter(file => !this.item_files.find(f => f.id === file.id));
|
||||||
|
if (new_files.length === 0) {
|
||||||
|
console.log("no new files");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$emit("change", [...this.item_files, ...new_files]);
|
||||||
|
},
|
||||||
|
deleteFile(file) {
|
||||||
|
this.deleteItemFile({item_id: this.item_id, file_id: file.id}).then(() => {
|
||||||
|
this.$emit("change", this.item_files.filter(f => f.id !== file.id));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteTempFile(file) {
|
||||||
|
this.$emit("change", this.item_files.filter(f => f.hash !== file.hash));
|
||||||
|
},
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
36
frontend/src/components/DeletableWrapper.vue
Normal file
36
frontend/src/components/DeletableWrapper.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<template>
|
||||||
|
<span style="position: relative; display: inline-block;">
|
||||||
|
<div class="delete-button">
|
||||||
|
<button class="btn btn-danger btn-sm" @click="triggerDelete">
|
||||||
|
<b-icon-trash></b-icon-trash>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<slot></slot>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.delete-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "DeletableWrapper",
|
||||||
|
components: {
|
||||||
|
...BIcons
|
||||||
|
},
|
||||||
|
emits: ["delete"],
|
||||||
|
methods: {
|
||||||
|
triggerDelete() {
|
||||||
|
this.$emit("delete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
88
frontend/src/components/DragDropFileSource.vue
Normal file
88
frontend/src/components/DragDropFileSource.vue
Normal file
|
|
@ -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>
|
||||||
59
frontend/src/components/FsFileSource.vue
Normal file
59
frontend/src/components/FsFileSource.vue
Normal file
|
|
@ -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>
|
||||||
201
frontend/src/components/WebcamFileSource.vue
Normal file
201
frontend/src/components/WebcamFileSource.vue
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
<template>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<label @click="open">
|
||||||
|
<slot></slot>
|
||||||
|
</label>
|
||||||
|
<div class="modal" :class="{'d-block': show_modal}" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-fullscreen">
|
||||||
|
<div class="modal-content p-2">
|
||||||
|
<button type="button" class="btn-close position-absolute fixed-top m-2"
|
||||||
|
style="right: 1rem; left: auto;"
|
||||||
|
@click="close"></button>
|
||||||
|
<div v-if="error" class="alert alert-danger" role="alert">
|
||||||
|
{{ lastError }}
|
||||||
|
</div>
|
||||||
|
<div class="row" v-if="capturing && !streaming">
|
||||||
|
<div class="spinner-grow text-danger mx-auto" role="status">
|
||||||
|
<span class="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
v-if="!capturing && dataImage"
|
||||||
|
class="img-fluid rounded mx-auto d-block w-75"
|
||||||
|
:src="dataImage"
|
||||||
|
alt="Image not available."
|
||||||
|
/>
|
||||||
|
<video
|
||||||
|
ref="video"
|
||||||
|
class="img-fluid rounded d-block img-preview w-75 mx-auto"
|
||||||
|
:class="{'d-none': !capturing}"
|
||||||
|
>
|
||||||
|
Video stream not available.
|
||||||
|
</video>
|
||||||
|
<canvas ref="canvas" class="img-fluid d-none img-preview"/>
|
||||||
|
<div class="position-absolute fixed-bottom text-center">
|
||||||
|
<div class="btn-group shadow">
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
:class="{'disabled': dataImage}"
|
||||||
|
@click="(!dataImage) && captureVideoImage()">
|
||||||
|
<b-icon-camera></b-icon-camera> Capture
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
:class="{'disabled': !dataImage}"
|
||||||
|
@click="(dataImage) && retake()"
|
||||||
|
>
|
||||||
|
<b-icon-trash></b-icon-trash> Retake
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:class="{'disabled': !dataImage}"
|
||||||
|
@click="(dataImage) && save()"
|
||||||
|
>
|
||||||
|
<b-icon-save></b-icon-save> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<select v-model="selectedCamera" v-on:change="onUserSelect"
|
||||||
|
class="form-select position-relative w-50 mx-auto mb-5 mt-2 shadow"
|
||||||
|
aria-label="Select Camera Source">
|
||||||
|
<option disabled value="">Select Camera Source</option>
|
||||||
|
<option v-for="camera in availableCameras" :key="camera.deviceId" :value="camera">
|
||||||
|
{{ camera.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "WebcamFileSource",
|
||||||
|
data: () => ({
|
||||||
|
lastError: undefined,
|
||||||
|
error: false,
|
||||||
|
show_modal: false,
|
||||||
|
availableCameras: [],
|
||||||
|
selectedCamera: undefined,
|
||||||
|
capturing: false,
|
||||||
|
streaming: false,
|
||||||
|
stream: undefined,
|
||||||
|
dataImage: undefined
|
||||||
|
}),
|
||||||
|
methods: {
|
||||||
|
async attemptGetUserMedia(constraints) {
|
||||||
|
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: constraints
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
if (error.name === "NotAllowedError") this.lastError = "Camera Permission Not Granted";
|
||||||
|
if (error.name === "NotReadableError") this.lastError = "Camera Hardware Error";
|
||||||
|
this.lastError = "Unknown Error"
|
||||||
|
});
|
||||||
|
if (this.stream) this.allowed = true;
|
||||||
|
},
|
||||||
|
async assignStream() {
|
||||||
|
console.log(this.stream.getTracks()[0]);
|
||||||
|
const track = this.stream.getTracks()[0];
|
||||||
|
if (track.getCapabilities) {
|
||||||
|
const capabilities = this.stream.getTracks()[0].getCapabilities();
|
||||||
|
await this.attemptGetUserMedia({
|
||||||
|
deviceId: capabilities.deviceId,
|
||||||
|
width: {ideal: capabilities.width.max},
|
||||||
|
height: {ideal: capabilities.height.max}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.capturing = true;
|
||||||
|
this.streaming = false;
|
||||||
|
const {video} = this.$refs;
|
||||||
|
video.srcObject = this.stream;
|
||||||
|
video.addEventListener('canplay', () => {
|
||||||
|
this.streaming = true;
|
||||||
|
localStorage.setItem("WebcamFileSource#previousDevice", this.selectedCamera.deviceId);
|
||||||
|
}, false);
|
||||||
|
await video.play();
|
||||||
|
},
|
||||||
|
closeStream() {
|
||||||
|
if (this.capturing) {
|
||||||
|
this.stream.getTracks().forEach(s => s.stop());
|
||||||
|
this.streaming = false;
|
||||||
|
this.stream = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async enumerateCameras() {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
this.availableCameras = devices.filter(device => device.kind === "videoinput");
|
||||||
|
},
|
||||||
|
async open() {
|
||||||
|
this.show_modal = true;
|
||||||
|
const previousDevice = localStorage.getItem("WebcamFileSource#previousDevice");
|
||||||
|
if (previousDevice) await this.attemptGetUserMedia({deviceId: previousDevice});
|
||||||
|
if (!this.stream) await this.attemptGetUserMedia({facingMode: "environment"});
|
||||||
|
if (!this.stream) await this.attemptGetUserMedia(true);
|
||||||
|
if (!this.stream) this.error = true;
|
||||||
|
await this.enumerateCameras();
|
||||||
|
this.selectedCamera = this.availableCameras.find(({deviceId}) => deviceId === this.stream.getTracks()[0].getSettings().deviceId);
|
||||||
|
await this.assignStream();
|
||||||
|
},
|
||||||
|
async onUserSelect() {
|
||||||
|
this.closeStream();
|
||||||
|
if (!this.dataImage) {
|
||||||
|
await this.attemptGetUserMedia({deviceId: this.selectedCamera.deviceId})
|
||||||
|
await this.assignStream();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
this.closeStream();
|
||||||
|
this.capturing = false;
|
||||||
|
this.show_modal = false;
|
||||||
|
this.dataImage = undefined;
|
||||||
|
},
|
||||||
|
captureVideoImage() {
|
||||||
|
const {video, canvas} = this.$refs;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
const {videoWidth, videoHeight} = video;
|
||||||
|
canvas.width = videoWidth;
|
||||||
|
canvas.height = videoHeight;
|
||||||
|
context.drawImage(video, 0, 0, videoWidth, videoHeight);
|
||||||
|
this.dataImage = canvas.toDataURL('image/jpeg', 0.5);
|
||||||
|
this.closeStream();
|
||||||
|
this.capturing = false;
|
||||||
|
},
|
||||||
|
retake() {
|
||||||
|
this.dataImage = undefined;
|
||||||
|
this.open();
|
||||||
|
},
|
||||||
|
save() {
|
||||||
|
const mimeType = this.dataImage.split(';')[0].split(':')[1];
|
||||||
|
const data = this.dataImage.split(',')[1];
|
||||||
|
const raw_data = atob(data);
|
||||||
|
const hash = nacl.crypto_hash(raw_data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), "");
|
||||||
|
const image = {
|
||||||
|
name: hash.slice(0, 12) + ".jpg",
|
||||||
|
size: raw_data.length,
|
||||||
|
mime_type: mimeType,
|
||||||
|
data: data,
|
||||||
|
hash: hash,
|
||||||
|
};
|
||||||
|
this.$emit('input', [image]);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
navigator.mediaDevices.addEventListener("devicechange", (event) => {
|
||||||
|
console.log("devicechange");
|
||||||
|
this.enumerateCameras();
|
||||||
|
if (this.availableCameras.findIndex(({deviceId}) => deviceId === this.selectedCamera.deviceId) === -1) {
|
||||||
|
this.closeStream();
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -77,6 +77,9 @@ export default createStore({
|
||||||
setDomains(state, domains) {
|
setDomains(state, domains) {
|
||||||
state.domains = domains;
|
state.domains = domains;
|
||||||
},
|
},
|
||||||
|
setFiles(state, files) {
|
||||||
|
state.files = files;
|
||||||
|
},
|
||||||
logout(state) {
|
logout(state) {
|
||||||
state.user = null;
|
state.user = null;
|
||||||
state.token = null;
|
state.token = null;
|
||||||
|
|
@ -232,6 +235,40 @@ export default createStore({
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
return await servers.delete(getters.signAuth, '/api/friends/' + id + '/')
|
return await servers.delete(getters.signAuth, '/api/friends/' + id + '/')
|
||||||
},
|
},
|
||||||
|
async fetchFiles({state, commit, dispatch, getters}) {
|
||||||
|
if (state.last_load.files > Date.now() - 1000 * 60 * 60 * 24) {
|
||||||
|
return state.files
|
||||||
|
}
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
const data = await servers.get(getters.signAuth, '/api/files/')
|
||||||
|
data.map(file => file.owner = state.user)
|
||||||
|
commit('setFiles', data)
|
||||||
|
state.last_load.files = Date.now()
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
async fetchFilesByItem({state, commit, dispatch, getters}, {id}) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
return await servers.get(getters.signAuth, '/api/item_files/' + id + '/')
|
||||||
|
},
|
||||||
|
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.name) {
|
||||||
|
data.owner = state.user
|
||||||
|
state.files.push(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteFile({state, dispatch, getters}, {id}) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
await servers.delete(getters.signAuth, '/api/files/' + id + '/')
|
||||||
|
state.files = state.files.filter(file => file.id !== id)
|
||||||
|
},
|
||||||
|
async deleteItemFile({state, dispatch, getters}, {item_id, file_id}) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
await servers.delete(getters.signAuth, '/api/item_files/' + item_id + '/' + file_id + '/')
|
||||||
|
state.files = state.files.filter(file => file.id !== file_id)
|
||||||
|
},
|
||||||
async fetchTags({state, commit, dispatch, getters}) {
|
async fetchTags({state, commit, dispatch, getters}) {
|
||||||
if (state.last_load.tags > Date.now() - 1000 * 60 * 60 * 24) {
|
if (state.last_load.tags > Date.now() - 1000 * 60 * 60 * 24) {
|
||||||
return state.tags
|
return state.tags
|
||||||
|
|
|
||||||
|
|
@ -48,24 +48,27 @@
|
||||||
<div class="col-12 col-md-6 col-lg-4 col-xl-3" v-for="item in inventory_items"
|
<div class="col-12 col-md-6 col-lg-4 col-xl-3" v-for="item in inventory_items"
|
||||||
:key="item.id">
|
:key="item.id">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img :src="item.image" class="card-img-top" :alt="item.image || '...'">
|
<img :src="item.image" class="" :alt="item.image">
|
||||||
|
<authenticated-image v-if="only_images(item.files).length > 0"
|
||||||
|
:src="only_images(item.files)[0].name"
|
||||||
|
:owner="only_images(item.files)[0].owner"
|
||||||
|
class="card-img-top img-preview"/>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-0">{{ item.name }}</h5>
|
<h5 class="card-title mb-0">
|
||||||
|
<router-link :to="`/inventory/${item.id}`">
|
||||||
|
{{ item.name }}
|
||||||
|
</router-link></h5>
|
||||||
<div class="card-text text-black-50">
|
<div class="card-text text-black-50">
|
||||||
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
|
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
|
||||||
<span class="float-right">{{ item.owned_quantity }}</span>
|
<span class="float-right">{{ item.owned_quantity }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<!--router-link :to="`/inventory/${item.id}`"
|
|
||||||
class="btn btn-primary btn-sm">
|
|
||||||
View
|
|
||||||
</router-link>
|
|
||||||
<button class="btn btn-danger btn-sm"
|
<button class="btn btn-danger btn-sm"
|
||||||
@click="deleteInventoryItem(item.id)">Delete
|
@click="deleteInventoryItem(item.id)">Delete
|
||||||
</button>
|
</button>
|
||||||
<router-link :to="`/inventory/${item.id}/edit`"
|
<router-link :to="`/inventory/${item.id}/edit`"
|
||||||
class="btn btn-primary btn-sm">Edit
|
class="btn btn-primary btn-sm">Edit
|
||||||
</router-link-->
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,6 +92,7 @@
|
||||||
import {mapActions, mapGetters, mapMutations, mapState} from "vuex";
|
import {mapActions, mapGetters, mapMutations, mapState} from "vuex";
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
|
import AuthenticatedImage from "../components/AuthenticatedImage.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Inventory",
|
name: "Inventory",
|
||||||
|
|
@ -98,6 +102,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
AuthenticatedImage,
|
||||||
BaseLayout,
|
BaseLayout,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
|
|
@ -107,6 +112,9 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchInventoryItems", "deleteInventoryItem"]),
|
...mapActions(["fetchInventoryItems", "deleteInventoryItem"]),
|
||||||
|
only_images(files) {
|
||||||
|
return files.filter(file => file.mime_type.startsWith("image/"));
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
await this.fetchInventoryItems()
|
await this.fetchInventoryItems()
|
||||||
|
|
@ -115,5 +123,7 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.img-preview {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -26,6 +26,14 @@
|
||||||
<label for="quantity" class="form-label">Quantity</label>
|
<label for="quantity" class="form-label">Quantity</label>
|
||||||
{{ item.owned_quantity }}
|
{{ item.owned_quantity }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="image" class="form-label">Image</label>
|
||||||
|
<div>
|
||||||
|
<authenticated-image v-for="file in item.files" :key="file.id" :src="file.name"
|
||||||
|
:owner="file.owner" class="img-thumbnail border-info"></authenticated-image>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -50,10 +58,15 @@
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import {mapActions, mapGetters} from "vuex";
|
import {mapActions, mapGetters} from "vuex";
|
||||||
|
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||||
|
import PropertyField from "../components/PropertyField.vue";
|
||||||
|
import TagField from "../components/TagField.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "InventoryDetail",
|
name: "InventoryDetail",
|
||||||
components: {
|
components: {
|
||||||
|
AuthenticatedImage,
|
||||||
|
PropertyField, TagField,
|
||||||
BaseLayout,
|
BaseLayout,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="image" class="form-label">Image</label>
|
||||||
|
<combined-file-field :item_files="item.files" :item_id="item.id"
|
||||||
|
@change="changeFiles"></combined-file-field>
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<button type="submit" class="btn btn-primary" style="width: 100%"
|
<button type="submit" class="btn btn-primary" style="width: 100%"
|
||||||
@click="updateInventoryItem(item)">Update
|
@click="updateInventoryItem(item)">Update
|
||||||
|
|
@ -58,6 +63,7 @@ import {mapActions, mapGetters, mapState} from "vuex";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import TagField from "@/components/TagField.vue";
|
import TagField from "@/components/TagField.vue";
|
||||||
import PropertyField from "@/components/PropertyField.vue";
|
import PropertyField from "@/components/PropertyField.vue";
|
||||||
|
import CombinedFileField from "@/components/CombinedFileField.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "InventoryEdit",
|
name: "InventoryEdit",
|
||||||
|
|
@ -65,6 +71,7 @@ export default {
|
||||||
BaseLayout,
|
BaseLayout,
|
||||||
TagField,
|
TagField,
|
||||||
PropertyField,
|
PropertyField,
|
||||||
|
CombinedFileField,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
|
@ -91,6 +98,9 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchInventoryItems", "updateInventoryItem", "fetchInfo"]),
|
...mapActions(["fetchInventoryItems", "updateInventoryItem", "fetchInfo"]),
|
||||||
|
changeFiles(files) {
|
||||||
|
this.inventory_items.find(item => item.id === parseInt(this.id)).files = files
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
await this.fetchInfo();
|
await this.fetchInfo();
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="image" class="form-label">Image</label>
|
||||||
|
<combined-file-field :item_files="item.files" create
|
||||||
|
@change="item.files = $event"></combined-file-field>
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<button type="submit" class="btn btn-primary" style="width: 100%"
|
<button type="submit" class="btn btn-primary" style="width: 100%"
|
||||||
@click="createInventoryItem(item).then(() => $router.push('/inventory'))">Add
|
@click="createInventoryItem(item).then(() => $router.push('/inventory'))">Add
|
||||||
|
|
@ -58,6 +63,7 @@ import {mapActions, mapState} from "vuex";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import TagField from "@/components/TagField.vue";
|
import TagField from "@/components/TagField.vue";
|
||||||
import PropertyField from "@/components/PropertyField.vue";
|
import PropertyField from "@/components/PropertyField.vue";
|
||||||
|
import CombinedFileField from "@/components/CombinedFileField.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "InventoryNew",
|
name: "InventoryNew",
|
||||||
|
|
@ -65,6 +71,7 @@ export default {
|
||||||
BaseLayout,
|
BaseLayout,
|
||||||
TagField,
|
TagField,
|
||||||
PropertyField,
|
PropertyField,
|
||||||
|
CombinedFileField,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue