stash
This commit is contained in:
parent
fa7d5f40a9
commit
a1da771507
12 changed files with 476 additions and 38 deletions
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<img :src="image_data" :alt="owner + ':' + src"/>
|
||||
<img :src="image_data" :title="owner + ':' + src"/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -12,28 +12,48 @@
|
|||
</ul>
|
||||
<hr>
|
||||
<div style="position: relative;">
|
||||
<authenticated-image v-for="file in only_images(item_files).filter(file => file.owner)" :key="file.id"
|
||||
:owner="file.owner" :src="file.name" class="img-thumbnail"/>
|
||||
<img v-for="file in only_images(item_files).filter(file => file.data)" :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 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>
|
||||
<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">
|
||||
<b-icon-plus></b-icon-plus>
|
||||
</label>
|
||||
</div>
|
||||
<input type="checkbox" id="file-dropdown" class="invisible-input">
|
||||
<label class="img-thumbnail btn btn-outline-primary" for="file-dropdown">
|
||||
<b-icon-plus></b-icon-plus>
|
||||
</label>
|
||||
<div class="dropdown-menu">
|
||||
<authenticated-image v-for="file in only_images(files)" :key="file.id" :src="file.name"
|
||||
:owner="file.owner"
|
||||
class="img-thumbnail"/>
|
||||
<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>
|
||||
|
|
@ -51,6 +71,12 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.image-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.invisible-input {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -59,11 +85,16 @@
|
|||
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;
|
||||
|
|
@ -78,12 +109,18 @@ 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";
|
||||
import WebcamFileSourceLegacy from "@/components/WebcamFileSourceLegacy.vue";
|
||||
|
||||
export default {
|
||||
name: "CombinedFileField",
|
||||
components: {
|
||||
WebcamFileSource,
|
||||
WebcamFileSourceLegacy,
|
||||
...BIcons,
|
||||
AuthenticatedImage,
|
||||
DeletableWrapper,
|
||||
DragDropFileSource,
|
||||
CameraFileSource,
|
||||
FsFileSource
|
||||
|
|
@ -106,7 +143,7 @@ export default {
|
|||
...mapState(["files"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchFiles", "pushFile"]),
|
||||
...mapActions(["fetchFiles", "pushFile", "deleteItemFile"]),
|
||||
async uploadFiles(files) {
|
||||
const jobs = files.map(async file => {
|
||||
return await this.pushFile({
|
||||
|
|
@ -114,22 +151,41 @@ export default {
|
|||
item_id: this.item_id
|
||||
});
|
||||
});
|
||||
const responses = await Promise.all(jobs);
|
||||
return responses;
|
||||
return await Promise.all(jobs);
|
||||
},
|
||||
addFiles(files) {
|
||||
const newfiles = files.filter(file => !this.item_files.find(f => f.hash === file.hash));
|
||||
if (newfiles.length === 0) {
|
||||
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(newfiles).then((uploaded) => {
|
||||
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/"));
|
||||
},
|
||||
|
|
|
|||
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>
|
||||
</router-link>
|
||||
</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">
|
||||
<router-link to="/admin" class="sidebar-link">
|
||||
<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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue