stash
This commit is contained in:
parent
17bfb84a94
commit
2fe8d14c2c
35 changed files with 1976 additions and 2134 deletions
204
frontend/src/components/inputs/BadgeSelectField.vue
Normal file
204
frontend/src/components/inputs/BadgeSelectField.vue
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<template>
|
||||
<div class="input-group">
|
||||
<div class="badge-list form-control form-control-lg" style="position: relative;">
|
||||
<span v-for="(option, index) in value" :key="index">
|
||||
<slot :option="option" :index="index"></slot>
|
||||
</span>
|
||||
<input type="text" class="invisible-input" v-model="textinput" ref="input"
|
||||
@keydown="keyDown">
|
||||
<ul class="dropdown-menu" style="position: absolute; top: 100%; left: 0; width: 100%; z-index: 100;"
|
||||
v-if="filteredOption.length > 0">
|
||||
<li v-for="(option, index) in filteredOption" :key="index"
|
||||
@mousedown="chooseOption(option)" @mouseover="selectOption(option)"
|
||||
:class="{chosen: localValue.includes(option), selected: selectedTag === option, 'dropdown-item': true}">
|
||||
{{ option }}
|
||||
<b>
|
||||
<b-icon-check class="checkmark"></b-icon-check>
|
||||
<b-icon-x class="cross"></b-icon-x>
|
||||
<b-icon-plus class="plus"></b-icon-plus>
|
||||
</b>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badge-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
|
||||
.badge-list > * {
|
||||
flex: 0 1 auto;
|
||||
padding: .25rem 0;
|
||||
}
|
||||
|
||||
.badge-list > input.invisible-input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
input.invisible-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #495057;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right .7rem center;
|
||||
background-size: 16px 12px;
|
||||
}
|
||||
|
||||
.invisible-input:focus ~ .dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
display: none;
|
||||
overflow-x: auto;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.checkmark, .cross, .plus {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-item.selected {
|
||||
background-color: var(--bs-primary);
|
||||
color: var(--bs-light);
|
||||
|
||||
& .plus {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item.chosen {
|
||||
& .checkmark {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item.chosen.selected {
|
||||
background-color: var(--bs-danger);
|
||||
color: var(--bs-light);
|
||||
|
||||
& .cross {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& .checkmark, & .plus {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: "BadgeSelectField",
|
||||
data() {
|
||||
return {
|
||||
textinput: "",
|
||||
selectedTag: ""
|
||||
}
|
||||
},
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
model: {
|
||||
prop: "value",
|
||||
event: "addElement"
|
||||
},
|
||||
computed: {
|
||||
availableTags() {
|
||||
return this.options.filter(option => !this.localValue.includes(option));
|
||||
},
|
||||
filteredOption() {
|
||||
return this.options.filter(option => option.includes(this.textinput) || option === this.selectedTag);
|
||||
},
|
||||
localValue: {
|
||||
get() {
|
||||
return this.value;
|
||||
},
|
||||
set(value) {
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
chooseOption(option) {
|
||||
this.$emit("addElement", option);
|
||||
},
|
||||
resetSelection() {
|
||||
this.textinput = "";
|
||||
this.selectedTag = null;
|
||||
},
|
||||
selectOption(option) {
|
||||
this.selectedTag = option;
|
||||
},
|
||||
keyDown(event) {
|
||||
const key = event.key;
|
||||
if (key === "Enter") {
|
||||
event.preventDefault();
|
||||
this.chooseOption(this.selectedTag);
|
||||
this.$refs.input.blur();
|
||||
} else if (key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
if (this.selectedTag === "") {
|
||||
this.selectedTag = this.filteredOption[0];
|
||||
} else {
|
||||
const index = this.filteredOption.indexOf(this.selectedTag);
|
||||
if (index < this.filteredOption.length - 1) {
|
||||
this.selectedTag = this.filteredOption[index + 1];
|
||||
} else {
|
||||
this.selectedTag = this.filteredOption[0];
|
||||
}
|
||||
setTimeout(() => {
|
||||
const element = this.$el.querySelector(".selected");
|
||||
if (element) {
|
||||
element.scrollIntoView({block: "nearest"});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
} else if (key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (this.selectedTag === "") {
|
||||
this.selectedTag = this.filteredOption[this.filteredOption.length - 1];
|
||||
} else {
|
||||
const index = this.filteredOption.indexOf(this.selectedTag);
|
||||
if (index > 0) {
|
||||
this.selectedTag = this.filteredOption[index - 1];
|
||||
} else {
|
||||
this.selectedTag = this.filteredOption[this.filteredOption.length - 1];
|
||||
}
|
||||
setTimeout(() => {
|
||||
const element = this.$el.querySelector(".selected");
|
||||
if (element) {
|
||||
element.scrollIntoView({block: "nearest"});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
} else if (key === "ArrowRight") {
|
||||
if (event.target.selectionStart === this.textinput.length) {
|
||||
event.preventDefault();
|
||||
this.chooseOption(this.selectedTag);
|
||||
}
|
||||
} else if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.$refs.input.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
76
frontend/src/components/inputs/CameraFileSource.vue
Normal file
76
frontend/src/components/inputs/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>
|
||||
88
frontend/src/components/inputs/DragDropFileSource.vue
Normal file
88
frontend/src/components/inputs/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/inputs/FsFileSource.vue
Normal file
59
frontend/src/components/inputs/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/inputs/WebcamFileSource.vue
Normal file
201
frontend/src/components/inputs/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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue