This commit is contained in:
parent
ec139531f2
commit
dd5c8e3d57
13 changed files with 506 additions and 52 deletions
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<img :src="image_data" :alt="owner + ':' + src"/>
|
<img :src="image_data" :title="owner + ':' + src"/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -12,10 +12,16 @@
|
||||||
</ul>
|
</ul>
|
||||||
<hr>
|
<hr>
|
||||||
<div style="position: relative;">
|
<div style="position: relative;">
|
||||||
<authenticated-image v-for="file in only_images(item_files).filter(file => file.owner)" :key="file.id"
|
<div class="image-list">
|
||||||
:owner="file.owner" :src="file.name" class="img-thumbnail"/>
|
<deletable-wrapper v-for="file in only_images(item_files).filter(file => file.owner)" :key="file.id"
|
||||||
<img v-for="file in only_images(item_files).filter(file => file.data)" :key="file.id" :alt="file.name"
|
@delete="deleteFile(file)">
|
||||||
:src="'data:' + file.mime_type + ';base64,' + file.data" class="img-thumbnail border-info">
|
<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">
|
<fs-file-source @input="addFiles">
|
||||||
<div class="img-thumbnail btn btn-outline-primary">
|
<div class="img-thumbnail btn btn-outline-primary">
|
||||||
<b-icon-upload></b-icon-upload>
|
<b-icon-upload></b-icon-upload>
|
||||||
|
@ -26,14 +32,28 @@
|
||||||
<b-icon-camera></b-icon-camera>
|
<b-icon-camera></b-icon-camera>
|
||||||
</div>
|
</div>
|
||||||
</camera-file-source>
|
</camera-file-source>
|
||||||
<input type="checkbox" id="file-dropdown" class="invisible-input">
|
<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>
|
||||||
|
<webcam-file-source-legacy @input="addFiles">
|
||||||
|
<div class="img-thumbnail btn btn-outline-primary">
|
||||||
|
<b-icon-camera-video></b-icon-camera-video>
|
||||||
|
</div>
|
||||||
|
</webcam-file-source-legacy>
|
||||||
<label class="img-thumbnail btn btn-outline-primary" for="file-dropdown">
|
<label class="img-thumbnail btn btn-outline-primary" for="file-dropdown">
|
||||||
<b-icon-plus></b-icon-plus>
|
<b-icon-plus></b-icon-plus>
|
||||||
</label>
|
</label>
|
||||||
<div class="dropdown-menu">
|
</div>
|
||||||
<authenticated-image v-for="file in only_images(files)" :key="file.id" :src="file.name"
|
<input type="checkbox" id="file-dropdown" class="invisible-input">
|
||||||
:owner="file.owner"
|
<div class="dropdown-menu" v-if="only_images(files).length > 0">
|
||||||
class="img-thumbnail"/>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</drag-drop-file-source>
|
</drag-drop-file-source>
|
||||||
|
@ -51,6 +71,12 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.invisible-input {
|
.invisible-input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@ -59,11 +85,16 @@
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu:hover {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
#file-dropdown:checked ~ label {
|
#file-dropdown:checked ~ label {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: var(--bs-primary);
|
background-color: var(--bs-primary);
|
||||||
border-color: var(--bs-primary);
|
border-color: var(--bs-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
#file-dropdown:checked ~ label:hover {
|
#file-dropdown:checked ~ label:hover {
|
||||||
color: var(--bs-primary);
|
color: var(--bs-primary);
|
||||||
background-color: initial;
|
background-color: initial;
|
||||||
|
@ -78,12 +109,18 @@ import DragDropFileSource from "@/components/DragDropFileSource.vue";
|
||||||
import FsFileSource from "@/components/FsFileSource.vue";
|
import FsFileSource from "@/components/FsFileSource.vue";
|
||||||
import CameraFileSource from "@/components/CameraFileSource.vue";
|
import CameraFileSource from "@/components/CameraFileSource.vue";
|
||||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||||
|
import DeletableWrapper from "@/components/DeletableWrapper.vue";
|
||||||
|
import WebcamFileSource from "@/components/WebcamFileSource.vue";
|
||||||
|
import WebcamFileSourceLegacy from "@/components/WebcamFileSourceLegacy.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "CombinedFileField",
|
name: "CombinedFileField",
|
||||||
components: {
|
components: {
|
||||||
|
WebcamFileSource,
|
||||||
|
WebcamFileSourceLegacy,
|
||||||
...BIcons,
|
...BIcons,
|
||||||
AuthenticatedImage,
|
AuthenticatedImage,
|
||||||
|
DeletableWrapper,
|
||||||
DragDropFileSource,
|
DragDropFileSource,
|
||||||
CameraFileSource,
|
CameraFileSource,
|
||||||
FsFileSource
|
FsFileSource
|
||||||
|
@ -106,7 +143,7 @@ export default {
|
||||||
...mapState(["files"]),
|
...mapState(["files"]),
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchFiles", "pushFile"]),
|
...mapActions(["fetchFiles", "pushFile", "deleteItemFile"]),
|
||||||
async uploadFiles(files) {
|
async uploadFiles(files) {
|
||||||
const jobs = files.map(async file => {
|
const jobs = files.map(async file => {
|
||||||
return await this.pushFile({
|
return await this.pushFile({
|
||||||
|
@ -114,22 +151,41 @@ export default {
|
||||||
item_id: this.item_id
|
item_id: this.item_id
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const responses = await Promise.all(jobs);
|
return await Promise.all(jobs);
|
||||||
return responses;
|
|
||||||
},
|
},
|
||||||
addFiles(files) {
|
addFiles(files) {
|
||||||
const newfiles = files.filter(file => !this.item_files.find(f => f.hash === file.hash));
|
console.log("add files", files);
|
||||||
if (newfiles.length === 0) {
|
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");
|
console.log("no new files");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.create) {
|
if (!this.create) {
|
||||||
this.uploadFiles(newfiles).then((uploaded) => {
|
this.uploadFiles(new_files).then((uploaded) => {
|
||||||
this.$emit("change", [...this.item_files, ...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) {
|
only_images(files) {
|
||||||
return files.filter(file => file.mime_type.startsWith("image/"));
|
return files.filter(file => file.mime_type.startsWith("image/"));
|
||||||
},
|
},
|
||||||
|
|
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>
|
|
@ -21,6 +21,12 @@
|
||||||
<span class="align-middle">Friends</span>
|
<span class="align-middle">Friends</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="sidebar-item">
|
||||||
|
<router-link to="/files" class="sidebar-link">
|
||||||
|
<b-icon-folder class="bi-valign-middle"></b-icon-folder>
|
||||||
|
<span class="align-middle">Files</span>
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
<li class="sidebar-item">
|
<li class="sidebar-item">
|
||||||
<router-link to="/admin" class="sidebar-link">
|
<router-link to="/admin" class="sidebar-link">
|
||||||
<b-icon-gear class="bi-valign-middle"></b-icon-gear>
|
<b-icon-gear class="bi-valign-middle"></b-icon-gear>
|
||||||
|
|
74
frontend/src/components/WebcamFileSource.vue
Normal file
74
frontend/src/components/WebcamFileSource.vue
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
<template>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<label @click="show_modal = true">
|
||||||
|
<slot></slot>
|
||||||
|
</label>
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-dialog modal-fullscreen">
|
||||||
|
<div class="modal-content">
|
||||||
|
<select v-model="selectedCamera" v-on:change="openStream" class="form-select position-relative w-75 mx-auto mt-4" 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>
|
||||||
|
<video
|
||||||
|
v-if="capturing"
|
||||||
|
ref="video"
|
||||||
|
class="img-fluid rounded mx-auto d-block mb-3 img-preview w-100"
|
||||||
|
>
|
||||||
|
Video stream not available.
|
||||||
|
</video>
|
||||||
|
<canvas ref="canvas" class="img-fluid d-none img-preview"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "WebcamFileSource",
|
||||||
|
data: () => ({
|
||||||
|
show_modal: false,
|
||||||
|
availableCameras: [],
|
||||||
|
selectedCamera: undefined,
|
||||||
|
capturing: false,
|
||||||
|
streaming: false,
|
||||||
|
stream: undefined,
|
||||||
|
dataImage: undefined
|
||||||
|
}),
|
||||||
|
methods: {
|
||||||
|
async openStream() {
|
||||||
|
if (!this.capturing) {
|
||||||
|
this.capturing = true;
|
||||||
|
this.streaming = false;
|
||||||
|
this.stream = await navigator.mediaDevices.getUserMedia({video: {deviceId: this.selectedCamera.deviceId}, audio: false});
|
||||||
|
const {video} = this.$refs;
|
||||||
|
video.srcObject = this.stream;
|
||||||
|
video.play();
|
||||||
|
video.addEventListener('canplay', () => {
|
||||||
|
this.streaming = true;
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async open() {
|
||||||
|
await navigator.mediaDevices.getUserMedia({video: true});
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
this.availableCameras = devices.filter(device => device.kind === "videoinput");
|
||||||
|
if (this.availableCameras.length === 0) return;
|
||||||
|
if (this.availableCameras.length === 1) {
|
||||||
|
this.selectedCamera = this.availableCameras[0];
|
||||||
|
await this.openStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
163
frontend/src/components/WebcamFileSourceLegacy.vue
Normal file
163
frontend/src/components/WebcamFileSourceLegacy.vue
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
<template>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<label @click="show_modal = true">
|
||||||
|
<slot></slot>
|
||||||
|
</label>
|
||||||
|
<div class="modal" :class="{'d-block': show_modal}" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-fullscreen">
|
||||||
|
<div class="modal-content">
|
||||||
|
<img
|
||||||
|
v-if="!capturing"
|
||||||
|
class="img-fluid rounded mx-auto d-block mb-3"
|
||||||
|
:src="dataImage"
|
||||||
|
alt="Image not available."
|
||||||
|
/>
|
||||||
|
<video
|
||||||
|
v-if="capturing"
|
||||||
|
ref="video"
|
||||||
|
class="img-fluid rounded mx-auto d-block mb-3"
|
||||||
|
>
|
||||||
|
Video stream not available.
|
||||||
|
</video>
|
||||||
|
<canvas ref="canvas" class="img-fluid d-none img-preview"/>
|
||||||
|
<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>
|
||||||
|
<div class="row m-auto">
|
||||||
|
<button v-if="!capturing" class="btn my-2 ml-auto btn-secondary" @click="openStream()">
|
||||||
|
<b-icon-camera></b-icon-camera>
|
||||||
|
</button>
|
||||||
|
<div v-if="capturing" class="btn-group my-2 ml-auto">
|
||||||
|
<button class="btn btn-success" @click="captureVideoImage()">
|
||||||
|
<b-icon-camera></b-icon-camera> Capture
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
:class="(dataImage) ? 'btn-danger' : 'btn-secondary disabled'"
|
||||||
|
@click="(dataImage) && closeStream()"
|
||||||
|
>
|
||||||
|
<b-icon-stop-fill></b-icon-stop-fill> Abort
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<select v-model="selectedCamera" class="form-select" aria-label="Select Camera Source"
|
||||||
|
@change="chooseDevice(selectedCamera)">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {mapMutations} from 'vuex';
|
||||||
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'WebcamFileSourceLegacy',
|
||||||
|
components: {
|
||||||
|
...BIcons
|
||||||
|
},
|
||||||
|
emits: ["input"],
|
||||||
|
data: () => ({
|
||||||
|
capturing: false,
|
||||||
|
streaming: false,
|
||||||
|
stream: undefined,
|
||||||
|
dataImage: undefined,
|
||||||
|
show_modal: false,
|
||||||
|
availableCameras: [],
|
||||||
|
selectedCamera: undefined,
|
||||||
|
}),
|
||||||
|
methods: {
|
||||||
|
async openStream() {
|
||||||
|
if (!this.capturing) {
|
||||||
|
//await navigator.mediaDevices.getUserMedia({video: true});
|
||||||
|
this.capturing = true;
|
||||||
|
this.streaming = false;
|
||||||
|
if (this.selectedCamera)
|
||||||
|
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {deviceId: this.selectedCamera.deviceId},
|
||||||
|
audio: false
|
||||||
|
});
|
||||||
|
else
|
||||||
|
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {facingMode: "environment"},
|
||||||
|
audio: false
|
||||||
|
})
|
||||||
|
const {video} = this.$refs;
|
||||||
|
video.srcObject = this.stream;
|
||||||
|
video.addEventListener('canplay', () => {
|
||||||
|
this.streaming = true;
|
||||||
|
}, false);
|
||||||
|
await video.play();
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
this.availableCameras = devices.filter(device => device.kind === "videoinput");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
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.show_modal = false;
|
||||||
|
this.$emit('input', [image]);
|
||||||
|
this.closeStream();
|
||||||
|
},
|
||||||
|
chooseDevice(device) {
|
||||||
|
this.closeStream();
|
||||||
|
this.selectedCamera = device;
|
||||||
|
this.openStream();
|
||||||
|
},
|
||||||
|
closeStream() {
|
||||||
|
if (this.capturing) {
|
||||||
|
this.stream.getTracks().forEach(s => s.stop());
|
||||||
|
this.capturing = false;
|
||||||
|
this.streaming = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
|
||||||
|
this.openStream();
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.closeStream();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
.camera-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, .5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -12,6 +12,7 @@ import InventoryNew from "@/views/InventoryNew.vue";
|
||||||
import InventoryEdit from "@/views/InventoryEdit.vue";
|
import InventoryEdit from "@/views/InventoryEdit.vue";
|
||||||
import InventoryDetail from "@/views/InventoryDetail.vue";
|
import InventoryDetail from "@/views/InventoryDetail.vue";
|
||||||
import Admin from "@/views/Admin.vue";
|
import Admin from "@/views/Admin.vue";
|
||||||
|
import Files from "@/views/Files.vue";
|
||||||
|
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
|
@ -23,6 +24,7 @@ const routes = [
|
||||||
{path: '/inventory/:id/edit', component: InventoryEdit, meta: {requiresAuth: true}, props: true},
|
{path: '/inventory/:id/edit', component: InventoryEdit, meta: {requiresAuth: true}, props: true},
|
||||||
{path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}},
|
{path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}},
|
||||||
{path: '/friends', component: Friends, meta: {requiresAuth: true}},
|
{path: '/friends', component: Friends, meta: {requiresAuth: true}},
|
||||||
|
{path: '/files', component: Files, meta: {requiresAuth: true}},
|
||||||
{path: '/admin', component: Admin, meta: {requiresAuth: true}},
|
{path: '/admin', component: Admin, meta: {requiresAuth: true}},
|
||||||
{path: '/search/:query', component: Search, meta: {requiresAuth: true}, props: true},
|
{path: '/search/:query', component: Search, meta: {requiresAuth: true}, props: true},
|
||||||
{path: '/login', component: Login, meta: {requiresAuth: false}},
|
{path: '/login', component: Login, meta: {requiresAuth: false}},
|
||||||
|
|
|
@ -27,6 +27,7 @@ export default createStore({
|
||||||
categories: [],
|
categories: [],
|
||||||
availability_policies: [],
|
availability_policies: [],
|
||||||
domains: [],
|
domains: [],
|
||||||
|
storage_locations: [],
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
setUser(state, user) {
|
setUser(state, user) {
|
||||||
|
@ -80,6 +81,9 @@ export default createStore({
|
||||||
setDomains(state, domains) {
|
setDomains(state, domains) {
|
||||||
state.domains = domains;
|
state.domains = domains;
|
||||||
},
|
},
|
||||||
|
setStorageLocations(state, storage_locations) {
|
||||||
|
state.storage_locations = storage_locations;
|
||||||
|
},
|
||||||
setFiles(state, files) {
|
setFiles(state, files) {
|
||||||
state.files = files;
|
state.files = files;
|
||||||
},
|
},
|
||||||
|
@ -187,11 +191,14 @@ export default createStore({
|
||||||
async createInventoryItem({state, dispatch, getters}, item) {
|
async createInventoryItem({state, dispatch, getters}, item) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
const data = {availability_policy: 'friends', ...item}
|
const data = {availability_policy: 'friends', ...item}
|
||||||
return await servers.post(getters.signAuth, '/api/inventory_items/', data)
|
const reply = await servers.post(getters.signAuth, '/api/inventory_items/', data)
|
||||||
|
state.last_load.files = 0
|
||||||
|
return reply
|
||||||
},
|
},
|
||||||
async updateInventoryItem({state, dispatch, getters}, item) {
|
async updateInventoryItem({state, dispatch, getters}, item) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
const data = {availability_policy: 'friends', ...item}
|
const data = {availability_policy: 'friends', ...item}
|
||||||
|
data.files = data.files.map(file => file.id)
|
||||||
return await servers.patch(getters.signAuth, '/api/inventory_items/' + item.id + '/', data)
|
return await servers.patch(getters.signAuth, '/api/inventory_items/' + item.id + '/', data)
|
||||||
},
|
},
|
||||||
async deleteInventoryItem({state, dispatch, getters}, item) {
|
async deleteInventoryItem({state, dispatch, getters}, item) {
|
||||||
|
@ -249,9 +256,13 @@ export default createStore({
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
async declineFriend({state, dispatch}, args) {
|
async declineFriend({state, dispatch, getters}, {id}) {
|
||||||
// TODO implement
|
const servers = await dispatch('getHomeServers')
|
||||||
console.log('declining friend ' + args)
|
return await servers.delete(getters.signAuth, '/api/friendrequests/' + id + '/')
|
||||||
|
},
|
||||||
|
async dropFriend({state, dispatch, getters}, {id}) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
return await servers.delete(getters.signAuth, '/api/friends/' + id + '/')
|
||||||
},
|
},
|
||||||
async fetchFiles({state, commit, dispatch, getters}) {
|
async fetchFiles({state, commit, dispatch, getters}) {
|
||||||
if (state.last_load.files > Date.now() - 1000 * 60 * 60 * 24) {
|
if (state.last_load.files > Date.now() - 1000 * 60 * 60 * 24) {
|
||||||
|
@ -277,6 +288,16 @@ export default createStore({
|
||||||
return 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
|
||||||
|
@ -317,6 +338,16 @@ export default createStore({
|
||||||
state.last_load.availability_policies = Date.now()
|
state.last_load.availability_policies = Date.now()
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
async fetchStorageLocations({state, commit, dispatch, getters}) {
|
||||||
|
if (state.last_load.storage_locations > Date.now() - 1000 * 60 * 60 * 24) {
|
||||||
|
return state.storage_locations
|
||||||
|
}
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
const data = await servers.get(getters.signAuth, '/api/storage_locations/')
|
||||||
|
commit('setStorageLocations', data)
|
||||||
|
state.last_load.storage_locations = Date.now()
|
||||||
|
return data
|
||||||
|
},
|
||||||
async fetchInfo({state, commit, dispatch, getters}) {
|
async fetchInfo({state, commit, dispatch, getters}) {
|
||||||
const last_load_info = Math.min(
|
const last_load_info = Math.min(
|
||||||
state.last_load.tags,
|
state.last_load.tags,
|
||||||
|
@ -331,7 +362,7 @@ export default createStore({
|
||||||
commit('setTags', data.tags)
|
commit('setTags', data.tags)
|
||||||
commit('setProperties', data.properties)
|
commit('setProperties', data.properties)
|
||||||
commit('setCategories', data.categories)
|
commit('setCategories', data.categories)
|
||||||
commit('setAvailabilityPolicies', data.policies)
|
commit('setAvailabilityPolicies', data.availability_policies)
|
||||||
commit('setDomains', data.domains)
|
commit('setDomains', data.domains)
|
||||||
state.last_load.tags = Date.now()
|
state.last_load.tags = Date.now()
|
||||||
state.last_load.properties = Date.now()
|
state.last_load.properties = Date.now()
|
||||||
|
|
|
@ -67,6 +67,18 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title">Storage Locations</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul>
|
||||||
|
<li v-for="location in storage_locations.sort()" :key="location.id">
|
||||||
|
{{ location.path }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -86,13 +98,14 @@ export default {
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(["tags", "properties", "categories", "availability_policies", "domains"]),
|
...mapState(["tags", "properties", "categories", "availability_policies", "domains", "storage_locations"])
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchInfo"]),
|
...mapActions(["fetchInfo", "fetchStorageLocations"])
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
await this.fetchInfo();
|
await this.fetchInfo();
|
||||||
|
await this.fetchStorageLocations();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
71
frontend/src/views/Files.vue
Normal file
71
frontend/src/views/Files.vue
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
<template>
|
||||||
|
<BaseLayout>
|
||||||
|
<main class="content">
|
||||||
|
<div class="container-fluid p-0">
|
||||||
|
<h1 class="h3 mb-3">Files</h1>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title">Images</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<span v-for="files in only_images(files)" :key="files.id">
|
||||||
|
<deletable-wrapper @delete="deleteFile(files)">
|
||||||
|
<authenticated-image :src="files.name" :owner="files.owner"
|
||||||
|
class="img-thumbnail"/>
|
||||||
|
</deletable-wrapper>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title">Other files</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul>
|
||||||
|
<li v-for="file in whithout_images(files)" :key="file.id">
|
||||||
|
{{ file.name }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</BaseLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {mapActions, mapState} from "vuex";
|
||||||
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
|
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||||
|
import DeletableWrapper from "@/components/DeletableWrapper.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "Files",
|
||||||
|
components: {
|
||||||
|
AuthenticatedImage,
|
||||||
|
BaseLayout,
|
||||||
|
DeletableWrapper,
|
||||||
|
...BIcons
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState(["files"])
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(["fetchFiles", "deleteFile"]),
|
||||||
|
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>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
|
@ -41,11 +41,11 @@
|
||||||
<td>{{ friend.username }}</td>
|
<td>{{ friend.username }}</td>
|
||||||
<td class="d-none d-md-table-cell">{{ friend.server.join(', ')}}</td>
|
<td class="d-none d-md-table-cell">{{ friend.server.join(', ')}}</td>
|
||||||
<td class="table-action">
|
<td class="table-action">
|
||||||
<a href="#" class="align-middle">
|
<!--a href="#" class="align-middle">
|
||||||
<b-icon-pencil-square></b-icon-pencil-square>
|
<b-icon-pencil-square></b-icon-pencil-square>
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</a-->
|
||||||
<a href="#" class="align-middle">
|
<a href="#" class="align-middle" @click="tryDropFriend(friend)">
|
||||||
<b-icon-trash></b-icon-trash>
|
<b-icon-trash></b-icon-trash>
|
||||||
Delete
|
Delete
|
||||||
</a>
|
</a>
|
||||||
|
@ -85,7 +85,7 @@
|
||||||
<b-icon-check></b-icon-check>
|
<b-icon-check></b-icon-check>
|
||||||
Accept
|
Accept
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-danger" @click="tryRejectFriend(request)">
|
<button class="btn btn-sm btn-danger" @click="tryDeclineFriend(request)">
|
||||||
<b-icon-x></b-icon-x>
|
<b-icon-x></b-icon-x>
|
||||||
Decline
|
Decline
|
||||||
</button>
|
</button>
|
||||||
|
@ -129,7 +129,8 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['fetchFriends', "lookupServer", "requestFriend", "acceptFriend", "fetchFriendRequests", "declineFriend"]),
|
...mapActions(['fetchFriends', "lookupServer", "requestFriend", "acceptFriend", "fetchFriendRequests",
|
||||||
|
"declineFriend", "dropFriend"]),
|
||||||
fetchContent() {
|
fetchContent() {
|
||||||
this.fetchFriends().then((friends) => {
|
this.fetchFriends().then((friends) => {
|
||||||
friends.map((friend) => {
|
friends.map((friend) => {
|
||||||
|
@ -163,8 +164,16 @@ export default {
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
tryRejectFriend(friend) {
|
tryDropFriend(friend) {
|
||||||
this.declineFriend({username: friend}).then((ok) => {
|
this.dropFriend(friend).then((ok) => {
|
||||||
|
if (ok) {
|
||||||
|
delete this.friends[friend.username]
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
})
|
||||||
|
},
|
||||||
|
tryDeclineFriend(request) {
|
||||||
|
this.declineFriend(request).then((ok) => {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
this.fetchContent()
|
this.fetchContent()
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="image" class="form-label">Image</label>
|
<label for="image" class="form-label">Image</label>
|
||||||
<combined-file-field :item_files="item.files" :item_id="item.id" @change="addFiles"></combined-file-field>
|
<combined-file-field :item_files="item.files" :item_id="item.id" @change="changeFiles"></combined-file-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
@ -85,7 +85,7 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchInventoryItems", "updateInventoryItem"]),
|
...mapActions(["fetchInventoryItems", "updateInventoryItem"]),
|
||||||
addFiles(files) {
|
changeFiles(files) {
|
||||||
this.inventory_items.find(item => item.id === parseInt(this.id)).files = files
|
this.inventory_items.find(item => item.id === parseInt(this.id)).files = files
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -6,13 +6,6 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">Create New Item</div>
|
<div class="card-header">Create New Item</div>
|
||||||
<div class="card-body">
|
<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">
|
<div class="mb-3">
|
||||||
<label for="name" class="form-label">Name</label>
|
<label for="name" class="form-label">Name</label>
|
||||||
<input type="text" class="form-control" id="name" name="name"
|
<input type="text" class="form-control" id="name" name="name"
|
||||||
|
|
Loading…
Reference in a new issue