move frontend to /web
This commit is contained in:
parent
9747c08bab
commit
dd75c2b0d6
36 changed files with 0 additions and 0 deletions
50
web/src/components/inputs/Addon.vue
Normal file
50
web/src/components/inputs/Addon.vue
Normal file
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<div class="input-group-append">
|
||||
<span
|
||||
class="input-group-text"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
:title="type"
|
||||
>{{ type[0] }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="'btn-' + color"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
:title="tooltip"
|
||||
>
|
||||
<font-awesome-icon :icon="icon"/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import $ from 'jquery';
|
||||
import 'bootstrap/js/dist/tooltip';
|
||||
|
||||
export default {
|
||||
name: 'Addon',
|
||||
props: [ 'type', 'isValid' ],
|
||||
mounted() {
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
},
|
||||
computed: {
|
||||
icon() {
|
||||
if (this.isValid == undefined) return 'pen';
|
||||
if (this.isValid == false) return 'times';
|
||||
return 'check';
|
||||
},
|
||||
color() {
|
||||
if (this.isValid == undefined) return 'info';
|
||||
if (this.isValid == false) return 'danger';
|
||||
return 'success';
|
||||
},
|
||||
tooltip() {
|
||||
if (this.isValid == undefined) return 'no data validation';
|
||||
if (this.isValid == false) return 'data invalid';
|
||||
return 'data valid';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
76
web/src/components/inputs/InputCombo.vue
Normal file
76
web/src/components/inputs/InputCombo.vue
Normal file
|
@ -0,0 +1,76 @@
|
|||
<template>
|
||||
<div class="form-group">
|
||||
<label :for="label">{{ label }}</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<button
|
||||
class="btn btn-outline-secondary dropdown-toggle"
|
||||
type="button"
|
||||
data-toggle="dropdown"
|
||||
>Search</button>
|
||||
<div class="dropdown-menu">
|
||||
<a
|
||||
v-for="(option, index) in sortedOptions"
|
||||
:key="index"
|
||||
class="dropdown-item"
|
||||
@click="setInternalValue(option)"
|
||||
:class="{ active: option == selectedOption }"
|
||||
>
|
||||
{{ option[nameKey] }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<input type="text" class="form-control" :id="label" v-model="internalName">
|
||||
<div class="input-group-append">
|
||||
<button
|
||||
class="btn"
|
||||
:class="{ 'btn-info disabled': isValid, 'btn-success': !isValid }"
|
||||
v-if="!isValid"
|
||||
@click="addOption()"
|
||||
>
|
||||
<font-awesome-icon icon="plus"/>
|
||||
</button>
|
||||
</div>
|
||||
<Addon type="Combo Box" :is-valid="isValid"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Addon from './Addon';
|
||||
|
||||
export default {
|
||||
name: 'InputCombo',
|
||||
components: {Addon},
|
||||
props: [ 'label', 'model', 'nameKey', 'uniqueKey', 'options', 'onOptionAdd' ],
|
||||
data: ({ options, model, nameKey, uniqueKey }) => ({
|
||||
internalName: model[nameKey],
|
||||
selectedOption: options.filter(e => e[uniqueKey] == model[uniqueKey])[0],
|
||||
addingOption: false
|
||||
}),
|
||||
computed: {
|
||||
isValid: ({options, nameKey, internalName}) => options.some(e => e[nameKey] == internalName),
|
||||
sortedOptions: ({options, nameKey}) => options.sort((a, b) => a[nameKey].localeCompare(b[nameKey], 'en', { numeric: true })),
|
||||
},
|
||||
watch: {
|
||||
internalName(newValue) {
|
||||
if (this.isValid) {
|
||||
if(!this.selectedOption || newValue!=this.selectedOption[this.nameKey]){
|
||||
this.selectedOption = this.options.filter(e => e[this.nameKey] === newValue)[0];
|
||||
}
|
||||
this.model[this.nameKey] = this.selectedOption[this.nameKey];
|
||||
this.model[this.uniqueKey] = this.selectedOption[this.uniqueKey];
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setInternalValue(option) {
|
||||
this.selectedOption = option;
|
||||
this.internalName = option[this.nameKey];
|
||||
},
|
||||
addOption() {
|
||||
this.onOptionAdd({[this.nameKey]: this.internalName});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
123
web/src/components/inputs/InputPhoto.vue
Normal file
123
web/src/components/inputs/InputPhoto.vue
Normal file
|
@ -0,0 +1,123 @@
|
|||
<template>
|
||||
<div>
|
||||
<img
|
||||
v-if="!capturing"
|
||||
class="img-fluid rounded mx-auto d-block mb-3 img-preview"
|
||||
:src="dataImage || `https://c3lf.de/api/1/thumbs/${model[field]}`"
|
||||
alt="Image not available."
|
||||
/>
|
||||
<video
|
||||
v-if="capturing"
|
||||
ref="video"
|
||||
class="img-fluid rounded mx-auto d-block mb-3 img-preview"
|
||||
>
|
||||
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">
|
||||
<label for="file" class="btn btn-info my-2">
|
||||
<font-awesome-icon icon="save"/> Upload an image instead
|
||||
</label>
|
||||
<input type="file" id="file" accept="image/png" class="d-none" @change="onFileChange($event)">
|
||||
<button v-if="!capturing" class="btn my-2 ml-auto btn-secondary" @click="openStream()">
|
||||
<font-awesome-icon icon="camera"/>
|
||||
</button>
|
||||
<div v-if="capturing" class="btn-group my-2 ml-auto">
|
||||
<button class="btn btn-success" @click="captureVideoImage()">
|
||||
<font-awesome-icon icon="camera"/> Capture
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
:class="(model[field] || dataImage) ? 'btn-danger' : 'btn-secondary disabled'"
|
||||
@click="(model[field] || dataImage) && closeStream()"
|
||||
>
|
||||
<font-awesome-icon icon="stop"/> Abort
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapMutations } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'InputPhoto',
|
||||
props: [ 'model', 'field', 'onCapture' ],
|
||||
data: () => ({
|
||||
capturing: false,
|
||||
streaming: false,
|
||||
stream: undefined,
|
||||
dataImage: undefined
|
||||
}),
|
||||
methods: {
|
||||
...mapMutations(['createToast']),
|
||||
openStream() {
|
||||
if (!this.capturing) {
|
||||
this.capturing = true;
|
||||
this.streaming = false;
|
||||
navigator.mediaDevices.getUserMedia({video: { facingMode: "environment" }, audio: false}).then(stream => {
|
||||
this.stream = stream;
|
||||
const { video } = this.$refs;
|
||||
video.srcObject = stream;
|
||||
video.play();
|
||||
video.addEventListener('canplay', () => {
|
||||
this.streaming = true;
|
||||
}, false);
|
||||
}).catch(err => console.log(err)); // todo: toast error
|
||||
}
|
||||
},
|
||||
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/png');
|
||||
this.onCapture(this.dataImage);
|
||||
this.closeStream();
|
||||
},
|
||||
closeStream() {
|
||||
if (this.capturing) {
|
||||
this.stream.getTracks().forEach(s => s.stop());
|
||||
this.capturing = false;
|
||||
this.streaming = false;
|
||||
}
|
||||
},
|
||||
onFileChange({ target }) {
|
||||
const file = target.files[0];
|
||||
var reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
const self = this;
|
||||
reader.onload = function () {
|
||||
self.dataImage = reader.result;
|
||||
self.onCapture(self.dataImage);
|
||||
self.closeStream();
|
||||
};
|
||||
reader.onerror = function (error) {
|
||||
this.createToast({ title: 'Error: Failed to parse image file', message: error.toString(), color: 'danger' });
|
||||
console.log('Error: ', error);
|
||||
};
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (!this.model[this.field])
|
||||
this.openStream();
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.closeStream();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.img-preview{
|
||||
max-height: 30vh;
|
||||
}
|
||||
</style>
|
29
web/src/components/inputs/InputString.vue
Normal file
29
web/src/components/inputs/InputString.vue
Normal file
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<div class="form-group">
|
||||
<label :for="label">{{ label }}</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" :id="label" v-model="model[field]">
|
||||
<Addon v-if="!hasValidationFn" type="String"/>
|
||||
<Addon v-if="hasValidationFn" type="String" :is-valid="isValid"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Addon from './Addon';
|
||||
|
||||
export default {
|
||||
name: 'InputString',
|
||||
components: { Addon },
|
||||
props: [ 'label', 'model', 'field', 'validationFn' ],
|
||||
computed: {
|
||||
hasValidationFn({ validationFn }) {
|
||||
return validationFn != undefined;
|
||||
},
|
||||
isValid({ model, field, validationFn, hasValidationFn }) {
|
||||
if (!hasValidationFn) return true;
|
||||
return validationFn(model[field]);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
Loading…
Add table
Add a link
Reference in a new issue