move frontend to /web

This commit is contained in:
j3d1 2023-11-18 12:51:24 +01:00
parent 9747c08bab
commit dd75c2b0d6
36 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,41 @@
<template>
<div>
<Modal v-if="isModal" title="Add Item" @close="$emit('close')">
<template #body>
<EditItem :item="item"/>
</template>
<template #buttons>
<button type="button" class="btn btn-secondary" @click="$emit('close')">Cancel</button>
<button type="button" class="btn btn-success" @click="saveNewItem()">Save new Item</button>
</template>
</Modal>
</div>
</template>
<script>
import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem';
export default {
name: 'AddItemModal',
components: { Modal, EditItem },
props: ['isModal'],
data: () => ({
item: {}
}),
created() {
this.item = {box: this.$store.state.lastUsed.box || '', cid: this.$store.state.lastUsed.cid || ''};
},
methods: {
saveNewItem() {
this.$store.dispatch('postItem', this.item).then(() => {
this.$emit('close');
});
}
}
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,68 @@
<template>
<div class="row">
<div class="col-lg-3 col-xl-2">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-body">
<h5 class="card-title text-info">Sort & Filter</h5>
<div class="form-group" v-for="(column, index) in columns" :key="index">
<label>{{ column }}</label>
<div class="input-group">
<div class="input-group-prepend">
<button
:class="[ 'btn', column === sortBy ? 'btn-outline-info' : 'btn-outline-secondary' ]"
type="button"
@click="toggleSort(column)">
<font-awesome-icon :icon="getSortIcon(column)"/>
</button>
</div>
<input
type="text"
class="form-control"
placeholder="filter"
:value="filters[column]"
@input="changeFilter(column, $event.target.value)"
>
<!-- <input @change="someHandler"> -->
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-9 col-xl-8">
<div class="row">
<div
class="mb-4 col-lg-4 col-xl-3"
v-for="item in internalItems"
:key="item[keyName]"
>
<div
class="card-list-item card bg-dark text-light"
@click="$emit('itemActivated', item)"
>
<slot v-bind:item="item"/>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import DataContainer from '@/mixins/data-container';
import router from '../router';
export default {
name: 'Cards',
mixins: [DataContainer],
created() {
this.columns.map(e => ({k: e, v: this.$store.getters.getFilters[e]})).filter(e => e.v).forEach(e => this.setFilter(e.k, e.v));
},
methods: {
changeFilter(col, val) {
this.setFilter(col, val);
let newquery = Object.entries({...this.$store.getters.getFilters, [col]: val}).reduce((a,[k,v]) => (v ? {...a, [k]:v} : a), {});
router.push({query: newquery});
},
},
};
</script>

View file

@ -0,0 +1,47 @@
<template>
<div>
<h6>Editing Item <span class="badge badge-secondary">#{{ item.uid }}</span></h6>
<InputPhoto
:model="item"
field="file"
:on-capture="storeImage"
/>
<InputString
label="description"
:model="item"
field="description"
:validation-fn="str => str && str.length > 0"
/>
<InputCombo
label="box"
:model="item"
nameKey="box"
uniqueKey="cid"
:options="boxes"
/>
</div>
</template>
<script>
import InputString from './inputs/InputString';
import InputCombo from './inputs/InputCombo';
import { mapGetters } from 'vuex';
import InputPhoto from './inputs/InputPhoto';
export default {
name: 'EditItem',
components: {InputPhoto, InputCombo, InputString },
props: ['item'],
computed: {
...mapGetters(['getBoxes']),
boxes({ getBoxes }) {
return getBoxes.map(obj => ({cid: obj.cid, box: obj.name}));
}
},
methods: {
storeImage(image) {
this.item.dataImage = image;
}
}
};
</script>

View file

@ -0,0 +1,36 @@
<template>
<Modal @close="$emit('close')">
<template #body>
<img
class="img-fluid rounded mx-auto d-block mb-3 w-100"
:src="`${baseUrl}/1/images/${file}`"
alt="Image not available."
id="lightbox-image"
>
</template>
<template #buttons>
<button type="button" class="btn btn-secondary" @click="$emit('close')">Cancel</button>
</template>
</Modal>
</template>
<script>
import Modal from '@/components/Modal';
import config from '../config';
export default {
name: 'Lightbox',
components: { Modal },
props: ['file'],
data: ()=>({
baseUrl: config.service.url,
}),
};
</script>
<style>
#lightbox-image {
max-height: 75vh;
object-fit: contain;
}
</style>

View file

@ -0,0 +1,75 @@
<template>
<div class="modal" tabindex="-1" @keyup.esc="$emit('close')" @click.self="$emit('close')">
<div class="modal-dialog modal-xl">
<div class="modal-content bg-dark text-light border-secondary">
<div class="modal-header">
<h5 class="modal-title">{{ title }}</h5>
<button type="button" class="close" @click="$emit('close')" aria-label="Close">
<font-awesome-icon icon="window-close" class="text-light"/>
</button>
</div>
<div class="modal-body">
<slot name="body">
<div class="alert alert-danger">
Modal body is empty
</div>
</slot>
</div>
<div class="modal-footer">
<slot name="buttons">
<div class="alert alert-danger">
Modal footer is empty
</div>
</slot>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Modal',
props: ['title'],
mounted() {
this.$el.focus();
}
};
</script>
<style>
.modal {
background-color: rgba(0,0,0,0.4); /* Transparent dimmed overlay */
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: table !important;
}
.modal.hidden {
display: none;
}
.modal .container {
display: table-cell;
text-align: center;
vertical-align: middle;
width: 200px;
}
.modal .body {
box-shadow: 5px 10px #888888;
display: inline-block;
background-color: white;
border: 1px solid black;
padding: 10px;
}
/* Hacky Fix, for this Issue: https://hannover.ccc.de/gitlab/c3lf/lffrontend/issues/26 */
/*.modal-body {
max-height: calc(100vh - 200px);
overflow-y: auto;
}*/
</style>

View file

@ -0,0 +1,96 @@
<template>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top">
<div class="dropdown">
<button class="btn text-light dropdown-toggle btn-heading" type="button" id="dropdownMenuButton"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{getEventSlug}}
</button>
<div class="dropdown-menu bg-dark" aria-labelledby="dropdownMenuButton">
<a class="dropdown-item text-light" href="#" v-for="(event, index) in events" v-bind:key="index"
:class="{ active: event.slug === getEventSlug }" @click="changeEvent(event)">{{ event.slug }}</a>
</div>
</div>
<div class="custom-control-inline mr-1">
<button type="button" class="btn mx-1 text-nowrap btn-success" @click="$emit('addClicked')">
<font-awesome-icon icon="plus"/><span class="d-none d-md-inline">&nbsp;Add</span>
</button>
<div class="btn-group btn-group-toggle">
<button :class="['btn', 'btn-info', { active: layout === 'cards' }]" @click="setLayout('cards')">
<font-awesome-icon icon="th"/>
</button>
<button :class="['btn', 'btn-info', { active: layout === 'table' }]" @click="setLayout('table')">
<font-awesome-icon icon="list"/>
</button>
</div>
</div>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<form class="form-inline mt-1 my-lg-auto my-xl-auto w-100 d-inline">
<input
class="form-control w-100"
type="search"
placeholder="Search"
aria-label="Search"
v-debounce:500ms="myFunc"
@input="searchEventItems($event.target.value)"
disabled
>
</form>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<li class="nav-item dropdown">
<button class="btn nav-link dropdown-toggle" type="button" id="dropdownMenuButton2"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{getActiveView}}
</button>
<ul class="dropdown-menu bg-dark" aria-labelledby="dropdownMenuButton2">
<li class="" v-for="(link, index) in views" v-bind:key="index" :class="{ active: link.path === getActiveView }">
<a class="nav-link text-nowrap" href="#" @click="changeView(link)">{{ link.title }}</a>
</li>
</ul>
</li>
<li class="nav-item" v-for="(link, index) in links" v-bind:key="index">
<a class="nav-link text-nowrap" :href="link.path">{{ link.title }}</a>
</li>
</ul>
</div>
</nav>
</template>
<script>
import { mapState, mapActions, mapMutations, mapGetters } from 'vuex';
export default {
name: 'Navbar',
data: () => ({
views: [
{'title':'items','path':'items'},
{'title':'boxes','path':'boxes'},
//{'title':'mass-edit','path':'massedit'},
],
links: [
{'title':'howto engel','path':'/howto/'}
]
}),
computed: {
...mapState(['events', 'activeEvent', 'layout']),
...mapGetters(['getEventSlug', 'getActiveView']),
},
methods: {
...mapActions(['changeEvent', 'changeView','searchEventItems']),
...mapMutations(['setLayout'])
}
};
</script>
<style lang="scss">
@import "../scss/navbar.scss";
</style>

View file

@ -0,0 +1,65 @@
<template>
<table class="table table-striped table-dark">
<thead>
<tr>
<th scope="col" v-for="(column, index) in columns" :key="index">
<div class="input-group">
<div class="input-group-prepend">
<button
:class="[ 'btn', column === sortBy ? 'btn-outline-info' : 'btn-outline-secondary' ]"
@click="toggleSort(column)"
>
{{ column }}
<span :class="{ 'text-info': column === sortBy }">
<font-awesome-icon :icon="getSortIcon(column)"/>
</span>
</button>
</div>
<input
type="text"
class="form-control"
placeholder="filter"
:value="filters[column]"
@input="changeFilter(column, $event.target.value)"
>
</div>
</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="item in internalItems" :key="item[keyName]" @click="$emit('itemActivated', item)">
<td v-for="(column, index) in columns" :key="index">{{ item[column] }}</td>
<td>
<slot v-bind:item="item"/>
</td>
</tr>
</tbody>
</table>
</template>
<script>
import DataContainer from '@/mixins/data-container';
import router from '../router';
export default {
name: 'Table',
mixins: [DataContainer],
created() {
this.columns.map(e => ({k: e, v: this.$store.getters.getFilters[e]})).filter(e => e.v).forEach(e => this.setFilter(e.k, e.v));
},
methods: {
changeFilter(col, val) {
this.setFilter(col, val);
let newquery = Object.entries({...this.$store.getters.getFilters, [col]: val}).reduce((a,[k,v]) => (v ? {...a, [k]:v} : a), {});
router.push({query: newquery});
},
},
};
</script>
<style>
.table-body-move {
transition: transform 1s;
}
</style>

View file

@ -0,0 +1,48 @@
<template>
<div class="toast" :class="color && ('border-' + color)" role="alert" ref="toast" data-autohide="false">
<div class="toast-header" :class="[color && ('bg-' + color), color && 'text-light']">
<strong class="mr-auto pr-3">{{ title }}</strong>
<small>{{ displayTime }}</small>
<button type="button" class="ml-2 mb-1 close" @click="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="toast-body" v-html="message">{{ message }}</div>
</div>
</template>
<script>
import $ from 'jquery';
import 'bootstrap/js/dist/toast';
import { DateTime } from 'luxon';
export default {
name: 'Toast',
props: ['title', 'message', 'color'],
data: () => ({
creationTime: DateTime.local(),
displayTime: 'just now',
timer: undefined
}),
mounted() {
const { toast } = this.$refs;
$(toast).toast('show');
this.timer = setInterval(this.updateDisplayTime, 1000);
},
methods: {
close() {
const { toast } = this.$refs;
$(toast).toast('hide');
window.setTimeout(() => {
this.$emit('close');
}, 500);
},
updateDisplayTime() {
this.displayTime = this.creationTime.toRelative();
}
},
beforeDestroy() {
clearInterval(this.timer);
}
};
</script>

View 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>

View 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>

View 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"/>&nbsp;Upload&nbsp;an&nbsp;image&nbsp;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"/>&nbsp;Capture
</button>
<button
class="btn"
:class="(model[field] || dataImage) ? 'btn-danger' : 'btn-secondary disabled'"
@click="(model[field] || dataImage) && closeStream()"
>
<font-awesome-icon icon="stop"/>&nbsp;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>

View 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>