move frontend to /web
This commit is contained in:
parent
9747c08bab
commit
dd75c2b0d6
36 changed files with 0 additions and 0 deletions
7
web/.editorconfig
Normal file
7
web/.editorconfig
Normal file
|
@ -0,0 +1,7 @@
|
|||
root = true
|
||||
|
||||
[*.js]
|
||||
indent_size = 2
|
||||
|
||||
[*.vue]
|
||||
indent_size = 2
|
36
web/.eslintrc.js
Normal file
36
web/.eslintrc.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
module.exports = {
|
||||
'env': {
|
||||
'browser': true,
|
||||
'es6': true
|
||||
},
|
||||
'extends': [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/essential'
|
||||
],
|
||||
'globals': {
|
||||
'Atomics': 'readonly',
|
||||
'SharedArrayBuffer': 'readonly'
|
||||
},
|
||||
'plugins': [
|
||||
'vue'
|
||||
],
|
||||
'rules': {
|
||||
"no-console": "off",
|
||||
'indent': [
|
||||
'error',
|
||||
2
|
||||
],
|
||||
'linebreak-style': [
|
||||
'off',
|
||||
'unix'
|
||||
],
|
||||
'quotes': [
|
||||
'error',
|
||||
'single'
|
||||
],
|
||||
'semi': [
|
||||
'error',
|
||||
'always'
|
||||
]
|
||||
}
|
||||
};
|
27
web/.gitignore
vendored
Normal file
27
web/.gitignore
vendored
Normal file
|
@ -0,0 +1,27 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
yarn.lock
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
src/store/auth.js
|
||||
src/config.js
|
60
web/API.md
Normal file
60
web/API.md
Normal file
|
@ -0,0 +1,60 @@
|
|||
# API 0.2
|
||||
Version major.minor
|
||||
- major: braking change
|
||||
- minor: compatible addition
|
||||
|
||||
## Notes
|
||||
json endpoints returning a list should always be encapsulated ba an object to make non-breaking api changes easier, like this example
|
||||
```json
|
||||
{"events":[{},{},{}]}
|
||||
```
|
||||
|
||||
##Endpoints
|
||||
### Items
|
||||
|
||||
#### ``GET /api/<version.major>/<event.id>/items``
|
||||
|
||||
#### ``GET /api/<version.major>/<event.id>/items/<search_query>``
|
||||
Returns all results of a fuzzy search over all items for <search_query>. The <search_query> should be url-encoded.
|
||||
|
||||
#### ``GET /api/<version.major>/<event.id>/item/<item.uid>``
|
||||
|
||||
#### ``DELETE /api/<version.major>/<event.id>/item/<item.uid>``
|
||||
|
||||
#### ``PUT /api/<version.major>/<event.id>/item/<item.uid>``
|
||||
|
||||
#### ``POST /api/<version.major>/<event.id>/item``
|
||||
|
||||
### Boxes
|
||||
|
||||
#### ``GET /api/<version.major>/boxes``
|
||||
|
||||
#### ``GET /api/<version.major>/box/<box.cid>``
|
||||
|
||||
#### ``DELETE /api/<version.major>/box/<box.cid>``
|
||||
|
||||
#### ``PUT /api/<version.major>/box/<box.cid>``
|
||||
|
||||
#### ``POST /api/<version.major>/box/<box.cid>``
|
||||
|
||||
### Events
|
||||
```json
|
||||
{
|
||||
"name":"36. Chaos Communication Congress",
|
||||
"slug":"36C3",
|
||||
"start": 1577401200,
|
||||
"end": 1577746800,
|
||||
"pre_start": 1576882800,
|
||||
"post_end": 1577919600
|
||||
}
|
||||
```
|
||||
|
||||
#### ``GET /api/<version.major>/events``
|
||||
|
||||
returns a list of all tracked events
|
||||
|
||||
### Files/Images
|
||||
|
||||
#### ``GET /api/<version.major>/images/<file.hash>``
|
||||
|
||||
#### ``GET /api/<version.major>/thumbs/<file.hash>``
|
24
web/README.md
Normal file
24
web/README.md
Normal file
|
@ -0,0 +1,24 @@
|
|||
# c3cloc
|
||||
|
||||
## Project setup
|
||||
```
|
||||
yarn install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
yarn serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
yarn build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
yarn lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
5
web/babel.config.js
Normal file
5
web/babel.config.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
};
|
11914
web/package-lock.json
generated
Normal file
11914
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
65
web/package.json
Normal file
65
web/package.json
Normal file
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"name": "c3cloc",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
||||
"@fortawesome/vue-fontawesome": "^0.1.8",
|
||||
"axios": "^0.19.0",
|
||||
"base-64": "^0.1.0",
|
||||
"bootstrap": "^4.3.1",
|
||||
"core-js": "^3.3.2",
|
||||
"dotenv-webpack": "^1.7.0",
|
||||
"jquery": "^3.4.1",
|
||||
"lodash": "^4.17.15",
|
||||
"luxon": "^1.21.3",
|
||||
"node-sass": "^4.13.0",
|
||||
"popper.js": "^1.16.0",
|
||||
"ramda": "^0.26.1",
|
||||
"sass-loader": "^8.0.0",
|
||||
"utf8": "^3.0.0",
|
||||
"vue": "^2.6.10",
|
||||
"vue-debounce": "^2.2.0",
|
||||
"vue-router": "^3.1.3",
|
||||
"vuex": "^3.1.2",
|
||||
"vuex-router-sync": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^4.0.0",
|
||||
"@vue/cli-plugin-eslint": "^4.0.0",
|
||||
"@vue/cli-service": "^4.0.0",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-plugin-vue": "^5.0.0",
|
||||
"vue-template-compiler": "^2.6.10"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/essential",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"rules": {},
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
}
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"autoprefixer": {}
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions"
|
||||
]
|
||||
}
|
BIN
web/public/favicon.ico
Normal file
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 31 KiB |
17
web/public/index.html
Normal file
17
web/public/index.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title>c3cloc</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but c3cloc doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
42
web/src/App.vue
Normal file
42
web/src/App.vue
Normal file
|
@ -0,0 +1,42 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<AddItemModal v-if="addModalOpen" @close="closeAddModal()" isModal="true"/>
|
||||
<Navbar @addClicked="openAddModal()"/>
|
||||
<router-view/>
|
||||
<div aria-live="polite" aria-atomic="true" class="d-flex justify-content-end align-items-start fixed-top mx-1 my-5 py-3" style="min-height: 200px; z-index: 100000; pointer-events: none">
|
||||
<Toast v-for="toast in toasts" :key="toast" :title="toast.title" :message="toast.message" :color="toast.color" @close="removeToast(toast.key)" style="pointer-events: auto"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Navbar from '@/components/Navbar';
|
||||
import AddItemModal from '@/components/AddItemModal';
|
||||
import Toast from './components/Toast';
|
||||
import { mapState, mapMutations } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
components: { Toast, Navbar, AddItemModal },
|
||||
computed: mapState(['loadedItems', 'layout', 'toasts']),
|
||||
data: () => ({
|
||||
addModalOpen: false
|
||||
}),
|
||||
methods: {
|
||||
...mapMutations(['removeToast']),
|
||||
openAddModal() {
|
||||
this.addModalOpen = true;
|
||||
},
|
||||
closeAddModal() {
|
||||
this.addModalOpen = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body, html, #app {
|
||||
background: #000;
|
||||
}
|
||||
</style>
|
||||
|
BIN
web/src/assets/logo.png
Normal file
BIN
web/src/assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.8 KiB |
41
web/src/components/AddItemModal.vue
Normal file
41
web/src/components/AddItemModal.vue
Normal 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>
|
68
web/src/components/Cards.vue
Normal file
68
web/src/components/Cards.vue
Normal 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>
|
47
web/src/components/EditItem.vue
Normal file
47
web/src/components/EditItem.vue
Normal 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>
|
36
web/src/components/Lightbox.vue
Normal file
36
web/src/components/Lightbox.vue
Normal 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>
|
75
web/src/components/Modal.vue
Normal file
75
web/src/components/Modal.vue
Normal 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>
|
96
web/src/components/Navbar.vue
Normal file
96
web/src/components/Navbar.vue
Normal 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"> 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>
|
65
web/src/components/Table.vue
Normal file
65
web/src/components/Table.vue
Normal 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>
|
48
web/src/components/Toast.vue
Normal file
48
web/src/components/Toast.vue
Normal 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">×</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>
|
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>
|
32
web/src/main.js
Normal file
32
web/src/main.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
import Vue from 'vue';
|
||||
import App from './App.vue';
|
||||
import { sync } from 'vuex-router-sync';
|
||||
import store from './store';
|
||||
import router from './router';
|
||||
|
||||
// bootstrap
|
||||
import 'jquery/dist/jquery.min.js';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'bootstrap/dist/js/bootstrap.min.js';
|
||||
|
||||
// fontawesome
|
||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||
import { faPlus, faCheckCircle, faEdit, faTrash, faCat, faSyncAlt, faSort, faSortUp, faSortDown, faTh, faList, faWindowClose, faCamera, faStop, faPen, faCheck, faTimes, faSave } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
|
||||
import vueDebounce from 'vue-debounce';
|
||||
|
||||
library.add(faPlus, faCheckCircle, faEdit, faTrash, faCat, faSyncAlt, faSort, faSortUp, faSortDown, faTh, faList, faWindowClose, faCamera, faStop, faPen, faCheck, faTimes, faSave);
|
||||
|
||||
Vue.component('font-awesome-icon', FontAwesomeIcon);
|
||||
|
||||
sync(store, router);
|
||||
|
||||
new Vue({
|
||||
el: '#app',
|
||||
store,
|
||||
router,
|
||||
render: h => h(App),
|
||||
});
|
||||
|
||||
Vue.use(vueDebounce);
|
39
web/src/mixins/data-container.js
Normal file
39
web/src/mixins/data-container.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
import * as R from 'ramda';
|
||||
|
||||
export default {
|
||||
props: ['columns', 'items', 'actions', 'keyName'],
|
||||
data: (self) => ({
|
||||
sortBy: self.keyName,
|
||||
ascend: true,
|
||||
filters: R.fromPairs(self.columns.map(column => [column, '']))
|
||||
}),
|
||||
computed: {
|
||||
internalItems() {
|
||||
const filtered = this.items.filter(item => this.columns
|
||||
.map(column => {
|
||||
const field = item[column] + '';
|
||||
const filter = this.filters[column];
|
||||
return field.toLowerCase().includes(filter.toLowerCase());
|
||||
}).reduce((acc, nxt) => acc && nxt, true)
|
||||
);
|
||||
const sortByOrd = R.sortBy(R.prop(this.sortBy));
|
||||
const sorted = sortByOrd(filtered, [this.sortBy]);
|
||||
return this.ascend ? sorted : R.reverse(sorted);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getSortIcon(column) {
|
||||
if (column !== this.sortBy) return 'sort';
|
||||
if (this.ascend) return 'sort-up';
|
||||
return 'sort-down';
|
||||
},
|
||||
toggleSort(column) {
|
||||
if (column === this.sortBy)
|
||||
this.ascend = !this.ascend;
|
||||
this.sortBy = column;
|
||||
},
|
||||
setFilter(column, filter) {
|
||||
this.filters[column] = filter;
|
||||
}
|
||||
}
|
||||
};
|
31
web/src/router.js
Normal file
31
web/src/router.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
import Items from './views/Items';
|
||||
import Boxes from './views/Boxes';
|
||||
import Files from './views/Files';
|
||||
import Events from './views/Events';
|
||||
import Error from './views/Error';
|
||||
import HowTo from './views/HowTo';
|
||||
import VueRouter from 'vue-router';
|
||||
import Vue from 'vue';
|
||||
|
||||
|
||||
|
||||
Vue.use(VueRouter);
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/Camp23/items' },
|
||||
{ path: '/howto', name: 'howto', component: HowTo},
|
||||
{ path: '/admin/files', name: 'files', component: Files},
|
||||
{ path: '/admin/events', name: 'events', component: Events},
|
||||
{ path: '/:event/boxes', name: 'boxes', component: Boxes},
|
||||
{ path: '/:event/items', name: 'items', component: Items},
|
||||
{ path: '/:event/box/:uid', name: 'boxes', component: Boxes},
|
||||
{ path: '/:event/item/:uid', name: 'items', component: Items},
|
||||
{ path: '*', component: Error},
|
||||
];
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: 'history',
|
||||
routes
|
||||
});
|
||||
|
||||
export default router;
|
10
web/src/scss/navbar.scss
Normal file
10
web/src/scss/navbar.scss
Normal file
|
@ -0,0 +1,10 @@
|
|||
button.btn-heading {
|
||||
font-size: 2em;
|
||||
padding: 0 .75rem;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
.btn-heading::after {
|
||||
font-size: 0.5em;
|
||||
line-height: 2em;
|
||||
}
|
149
web/src/store/index.js
Normal file
149
web/src/store/index.js
Normal file
|
@ -0,0 +1,149 @@
|
|||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import AxiosBootstrap from 'axios';
|
||||
import config from '../config';
|
||||
import * as _ from 'lodash/fp';
|
||||
import router from '../router';
|
||||
|
||||
import * as base64 from 'base-64';
|
||||
import * as utf8 from 'utf8';
|
||||
|
||||
Vue.use(Vuex);
|
||||
const axios = AxiosBootstrap.create({
|
||||
baseURL: config.service.url,
|
||||
auth: config.service.auth
|
||||
});
|
||||
|
||||
axios.interceptors.response.use(response => response, error => {
|
||||
console.log('error interceptor fired');
|
||||
console.error(error); // todo: toast error
|
||||
console.log(Object.entries(error));
|
||||
|
||||
if (error.isAxiosError) {
|
||||
const message = `
|
||||
<h3>A HTTP ${error.config.method} request failed.</h3>
|
||||
<p>
|
||||
url: ${error.config.url}
|
||||
<br>
|
||||
timeout: ${!!error.request.timeout}
|
||||
<br>
|
||||
response-body: ${error.response && error.response.body}
|
||||
</p>
|
||||
`;
|
||||
store.commit('createToast', {title: 'Error: HTTP', message, color: 'danger'});
|
||||
} else {
|
||||
store.commit('createToast', {title: 'Error: Unknown', message: error.toString(), color: 'danger'});
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
const store = new Vuex.Store({
|
||||
state: {
|
||||
keyIncrement: 0,
|
||||
events: [],
|
||||
layout: 'cards',
|
||||
loadedItems: [],
|
||||
loadedBoxes: [],
|
||||
toasts: [],
|
||||
lastUsed: localStorage.getItem('lf_lastUsed') || {},
|
||||
},
|
||||
getters: {
|
||||
getEventSlug: state => state.route && state.route.params.event? state.route.params.event : state.events.length ? state.events[0].slug : '36C3',
|
||||
getActiveView: state => state.route.name || 'items',
|
||||
getFilters: state => state.route.query,
|
||||
getBoxes: state => state.loadedBoxes
|
||||
},
|
||||
mutations: {
|
||||
updateLastUsed(state, diff) {
|
||||
state.lastUsed = _.extend(state.lastUsed, diff);
|
||||
localStorage.setItem('lf_lastUsed', state.lastUsed);
|
||||
},
|
||||
replaceEvents(state, events) {
|
||||
state.events = events;
|
||||
},
|
||||
changeView(state, {view, slug}) {
|
||||
router.push({path: `/${slug}/${view}`});
|
||||
},
|
||||
replaceLoadedItems(state, newItems) {
|
||||
state.loadedItems = newItems;
|
||||
},
|
||||
setLayout(state, layout) {
|
||||
state.layout = layout;
|
||||
},
|
||||
replaceBoxes(state, loadedBoxes) {
|
||||
state.loadedBoxes = loadedBoxes;
|
||||
},
|
||||
updateItem(state, updatedItem) {
|
||||
const item = state.loadedItems.filter(({ uid }) => uid === updatedItem.uid)[0];
|
||||
Object.assign(item, updatedItem);
|
||||
},
|
||||
removeItem(state, item) {
|
||||
state.loadedItems = state.loadedItems.filter(it => it !== item );
|
||||
},
|
||||
appendItem(state, item) {
|
||||
state.loadedItems.push(item);
|
||||
},
|
||||
createToast(state, { title, message, color }) {
|
||||
state.toasts.push({ title, message, color, key: state.keyIncrement });
|
||||
state.keyIncrement += 1;
|
||||
},
|
||||
removeToast(state, key) {
|
||||
state.toasts = state.toasts.filter(toast => toast.key !== key);
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async loadEvents({ commit }) {
|
||||
const { data } = await axios.get('/1/events');
|
||||
commit('replaceEvents', data);
|
||||
},
|
||||
changeEvent({ dispatch, getters}, eventName) {
|
||||
router.push({path: `/${eventName.slug}/${getters.getActiveView}`});
|
||||
dispatch('loadEventItems');
|
||||
},
|
||||
changeView({ getters }, link) {
|
||||
router.push({path: `/${getters.getEventSlug}/${link.path}`});
|
||||
},
|
||||
showBoxContent({ getters }, box) {
|
||||
router.push({path: `/${getters.getEventSlug}/items`, query: {box}});
|
||||
},
|
||||
async loadEventItems({ commit, getters }) {
|
||||
const { data } = await axios.get(`/1/${getters.getEventSlug}/items`);
|
||||
commit('replaceLoadedItems', data);
|
||||
},
|
||||
async searchEventItems({ commit, getters }, query) {
|
||||
const foo = utf8.encode(query);
|
||||
const bar = base64.encode(foo);
|
||||
|
||||
const {data} = await axios.get(`/1/${getters.getEventSlug}/items/${bar}`);
|
||||
commit('replaceLoadedItems', data);
|
||||
},
|
||||
async loadBoxes({ commit }) {
|
||||
const { data } = await axios.get('/1/boxes');
|
||||
commit('replaceBoxes', data);
|
||||
},
|
||||
async updateItem({ commit, getters }, item) {
|
||||
const { data } = await axios.put(`/1/${getters.getEventSlug}/item/${item.uid}`, item);
|
||||
commit('updateItem', data);
|
||||
},
|
||||
async markItemReturned({ commit, getters }, item) {
|
||||
await axios.put(`/1/${getters.getEventSlug}/item/${item.uid}`, {returned: true});
|
||||
commit('removeItem', item);
|
||||
},
|
||||
async deleteItem({ commit, getters }, item) {
|
||||
await axios.delete(`/1/${getters.getEventSlug}/item/${item.uid}`, item);
|
||||
commit('removeItem',item);
|
||||
},
|
||||
async postItem({ commit, getters }, item) {
|
||||
commit('updateLastUsed',{box: item.box, cid: item.cid});
|
||||
const { data } = await axios.post(`/1/${getters.getEventSlug}/item`, item);
|
||||
commit('appendItem', data);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default store;
|
||||
|
||||
store.dispatch('loadEvents').then(() =>{
|
||||
store.dispatch('loadEventItems');
|
||||
store.dispatch('loadBoxes');
|
||||
});
|
39
web/src/views/Boxes.vue
Normal file
39
web/src/views/Boxes.vue
Normal file
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<div class="container-fluid px-xl-5 mt-3">
|
||||
<div class="row">
|
||||
<div class="col-xl-8 offset-xl-2">
|
||||
<Table
|
||||
:columns="['cid', 'name','itemCount']"
|
||||
:items="loadedBoxes"
|
||||
:keyName="'cid'"
|
||||
v-slot="{ item }"
|
||||
>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary" @click.stop="showBoxContent(item.name)" >
|
||||
<!--font-awesome-icon icon="archive"/--> content
|
||||
</button>
|
||||
<button class="btn btn-danger" @click.stop="" title="delete">
|
||||
<font-awesome-icon icon="trash"/>
|
||||
</button>
|
||||
</div>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapState} from 'vuex';
|
||||
import Table from '@/components/Table';
|
||||
|
||||
export default {
|
||||
name: 'Boxes',
|
||||
components: {Table},
|
||||
computed: mapState(['loadedBoxes', 'layout']),
|
||||
methods: mapActions(['showBoxContent']),
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
13
web/src/views/Error.vue
Normal file
13
web/src/views/Error.vue
Normal file
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
<p>Error</p>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Error',
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
39
web/src/views/Events.vue
Normal file
39
web/src/views/Events.vue
Normal file
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<div class="container-fluid px-xl-5 mt-3">
|
||||
<div class="row">
|
||||
<div class="col-xl-8 offset-xl-2">
|
||||
<Table
|
||||
:columns="['slug', 'name']"
|
||||
:items="events"
|
||||
:keyName="'slug'"
|
||||
v-slot="{ item }"
|
||||
>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary" @click.stop="changeEvent(item)" >
|
||||
<font-awesome-icon icon="archive"/> use
|
||||
</button>
|
||||
<button class="btn btn-danger" @click.stop="" >
|
||||
<font-awesome-icon icon="trash"/> delete
|
||||
</button>
|
||||
</div>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapState} from 'vuex';
|
||||
import Table from '@/components/Table';
|
||||
|
||||
export default {
|
||||
name: 'Events',
|
||||
components: {Table},
|
||||
computed: mapState(['events']),
|
||||
methods: mapActions(['changeEvent']),
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
36
web/src/views/Files.vue
Normal file
36
web/src/views/Files.vue
Normal file
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<div class="container-fluid px-xl-5 mt-3">
|
||||
<div class="row">
|
||||
<div class="col-xl-8 offset-xl-2">
|
||||
<Table
|
||||
:columns="['hash', 'uid']"
|
||||
:items="loadedItems"
|
||||
:keyName="'hash'"
|
||||
v-slot="{ item }"
|
||||
>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-danger" @click.stop="" >
|
||||
<font-awesome-icon icon="trash"/> delete
|
||||
</button>
|
||||
</div>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapState} from 'vuex';
|
||||
import Table from '@/components/Table';
|
||||
|
||||
export default {
|
||||
name: 'Files',
|
||||
components: {Table},
|
||||
computed: mapState(['loadedItems']),
|
||||
methods: mapActions([]),
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
128
web/src/views/HowTo.vue
Normal file
128
web/src/views/HowTo.vue
Normal file
|
@ -0,0 +1,128 @@
|
|||
<template>
|
||||
<div class="container-fluid px-xl-5 mt-3" style="color: #f8f9fa !important;">
|
||||
<div class="row">
|
||||
<div class="col-xl-8 offset-xl-2">
|
||||
<b>[en below]</b>
|
||||
<h2>Lost&Found (Deutsche Version)</h2>
|
||||
<p>Herzlich Willkommen bei Lost&Found von $Veranstaltung!</p>
|
||||
<p>Deine Aufgaben sind es verloren gegangene Gegenstände anzunehmen und zu registrieren, sowie
|
||||
Gegenstände ihren Besitzenden zurückzubringen.</p>
|
||||
<p><b>Bitte den Inhalt des Lost+Founds nicht offen liegen lassen oder rumzeigen. Erst beschreiben
|
||||
lassen, dann zeigen.</b></p>
|
||||
<h3>Found (Jemand bringt einen verloren gegangen Gegenstand vorbei)</h3>
|
||||
<ul>
|
||||
<li>Möglichst viele Informationen über die Umstände heraus
|
||||
<ul>
|
||||
<li>Wo wurde der Gegenstand gefunden?</li>
|
||||
<li>Zu welcher Uhrzeit wurde der Gegenstand gefunden?</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Der Gegenstand wird in das c3lf-System eingetragen
|
||||
<ul>
|
||||
<li>Foto vom Gegenstand mit der Webcam machen</li>
|
||||
<li>Beschreibung des Gegenstands eintragen (gerne wie Tags behandeln die durch Leerzeichen getrennt
|
||||
sind)
|
||||
</li>
|
||||
<li>Nummer der Box eintragen in die der Gegenstand gelegt wird</li>
|
||||
<li>Gegenstand in die Box legen</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Lost (Jemand versucht einen Gegenstand wiederzufinden)</h3>
|
||||
<ul>
|
||||
<li>Jemand möchte einen Gegenstand wiederfinden</li>
|
||||
<li>Die Person soll möglichst genau beschreiben was sie sucht (insbesondere bei wertvolleren Dingen)</li>
|
||||
<li>Du schaust im System nach ob der Gegenstand vorhanden ist</li>
|
||||
<li>Wenn ein passender Gegenstand gefunden ist:
|
||||
<ul>
|
||||
<li>Gegenstand aus der Box holen und der Person aushändigen</li>
|
||||
<li>Grünen Haken beim Gegenstand setzen</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Wenn kein passender Gegenstand existiert:
|
||||
<ul>
|
||||
<li>Sagen, dass es den Gegenstand nicht gibt</li>
|
||||
<li>Darauf verweisen dass die Person entweder zu späterem Zeitpunkt wiederkommt, oder ein Ticket bei <a
|
||||
href="mailto:camp23@c3lf.de" target="_blank" rel="noopener">camp23@c3lf.de</a> aufmacht. Außerdem ist es
|
||||
gut möglich dass der Himmel und andere Villages eigene, kleinere Lost+Founds während des Events
|
||||
aufgemacht haben. Rumfragen lohnt sich also.
|
||||
<ul>
|
||||
<li><b>Achtung: Tickets werden erst nach Ende des Events bearbeitet.</b></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Eskalation</h3>
|
||||
<ul>
|
||||
<li>Bei Lost&Found bezogenen Problemen: 1033 (Lost&Found Orga) anrufen</li>
|
||||
<li>Bei Engel bezogenen Problemen: 1023 (Himmel) anrufen</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2>Lost&Found (English version)</h2>
|
||||
<p>Welcome to Lost&Found of $Event!</p>
|
||||
<p>Your tasks are to accept and register lost items, as well as to return items to their owners.</p>
|
||||
<p><b>Please do not leave the contents of the Lost+Found lying around or show them around. Ask for a description of the item first, then show them (if the description seems correct).</b></p>
|
||||
<h3>Found (Someone brings a lost item)</h3>
|
||||
<ul>
|
||||
<li>Get as much information as possible about the circumstances
|
||||
<ul>
|
||||
<li>Where was the item found?</li>
|
||||
<li>At what time was the item found?</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>The item is entered into the c3lf system
|
||||
<ul>
|
||||
<li>Take a photo of the item with the webcam</li>
|
||||
<li>Enter a description of the item (preferably treat it like tags separated by spaces)</li>
|
||||
<li>Enter the number of the box into which the item is to be placed</li>
|
||||
<li>Place the item in the box</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Lost (Someone tries to find an item)</h3>
|
||||
<ul>
|
||||
<li>Someone wants to find an item</li>
|
||||
<li>The person should describe as precisely as possible what they are looking for (especially for more
|
||||
valuable things)
|
||||
</li>
|
||||
<li>You check the system to see if the item is available</li>
|
||||
<li>If a suitable item is found:
|
||||
<ul>
|
||||
<li>Take the item out of the box and hand it over to the person</li>
|
||||
<li>Set a green check mark for the item</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>If no suitable item exists:
|
||||
<ul>
|
||||
<li>Say that the item does not exist</li>
|
||||
<li>Point out that the person either comes back at a later time, or opens a ticket at <a
|
||||
href="mailto:camp23@c3lf.de" target="_blank" rel="noopener">camp23@c3lf.de</a>. It is also possible that
|
||||
the Himmel and other Villages have their own, smaller Lost+Founds during the event. So it's worth asking
|
||||
around.
|
||||
<ul>
|
||||
<li><b>Attention: Tickets will not be processed until after the event.</b></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Escalation</h3>
|
||||
<ul>
|
||||
<li>For Lost&Found related problems: call 1033 (Lost&Found Orga)</li>
|
||||
<li>For angel related problems: call 1023 (Heaven)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'HowTo',
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
114
web/src/views/Items.vue
Normal file
114
web/src/views/Items.vue
Normal file
|
@ -0,0 +1,114 @@
|
|||
<template>
|
||||
<div class="container-fluid px-xl-5 mt-3">
|
||||
<Modal title="Edit Item" v-if="editingItem" @close="closeEditingModal()">
|
||||
<template #body>
|
||||
<EditItem
|
||||
:item="editingItem"
|
||||
badge="uid"
|
||||
/>
|
||||
</template>
|
||||
<template #buttons>
|
||||
<button type="button" class="btn btn-secondary" @click="closeEditingModal()">Cancel</button>
|
||||
<button type="button" class="btn btn-success" @click="saveEditingItem()">Save Changes</button>
|
||||
</template>
|
||||
</Modal>
|
||||
<Lightbox v-if="lightboxItem" :file="lightboxItem.file" @close="closeLightboxModal()"/>
|
||||
<div class="row" v-if="layout === 'table'">
|
||||
<div class="col-xl-8 offset-xl-2">
|
||||
<Table
|
||||
:columns="['uid', 'description', 'box']"
|
||||
:items="loadedItems"
|
||||
:keyName="'uid'"
|
||||
v-slot="{ item }"
|
||||
@itemActivated="openLightboxModalWith($event)"
|
||||
>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-success" @click.stop="markItemReturned(item)" title="returned">
|
||||
<font-awesome-icon icon="check"/>
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click.stop="openEditingModalWith(item)" title="edit">
|
||||
<font-awesome-icon icon="edit"/>
|
||||
</button>
|
||||
<button class="btn btn-danger" @click.stop="deleteItem(item)" title="delete">
|
||||
<font-awesome-icon icon="trash"/>
|
||||
</button>
|
||||
</div>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<Cards
|
||||
v-if="layout === 'cards'"
|
||||
:columns="['uid', 'description', 'box']"
|
||||
:items="loadedItems"
|
||||
:keyName="'uid'"
|
||||
v-slot="{ item }"
|
||||
@itemActivated="openLightboxModalWith($event)"
|
||||
>
|
||||
<img
|
||||
:src="`${baseUrl}/1/thumbs/${item.file}`"
|
||||
class="card-img-top img-fluid"
|
||||
>
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">{{ item.description }}</h6>
|
||||
<h6 class="card-subtitle text-secondary">uid: {{ item.uid }} box: {{ item.box }}</h6>
|
||||
<div class="row mx-auto mt-2">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-outline-success" @click.stop="markItemReturned(item)" title="returned">
|
||||
<font-awesome-icon icon="check"/>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @click.stop="openEditingModalWith(item)" title="edit">
|
||||
<font-awesome-icon icon="edit"/>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger" @click.stop="deleteItem(item)" title="delete">
|
||||
<font-awesome-icon icon="trash"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Cards>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Table from '@/components/Table';
|
||||
import Cards from '@/components/Cards';
|
||||
import Modal from '@/components/Modal';
|
||||
import EditItem from '@/components/EditItem';
|
||||
import {mapActions, mapState} from 'vuex';
|
||||
import config from '../config';
|
||||
import Lightbox from '../components/Lightbox';
|
||||
|
||||
export default {
|
||||
name: 'Items',
|
||||
data: () => ({
|
||||
lightboxItem: null,
|
||||
editingItem: null,
|
||||
baseUrl: config.service.url,
|
||||
}),
|
||||
components: {Lightbox, Table, Cards, Modal, EditItem },
|
||||
computed: mapState(['loadedItems', 'layout']),
|
||||
methods: {
|
||||
...mapActions(['deleteItem','markItemReturned']),
|
||||
openLightboxModalWith(item) { // Opens the editing modal with a copy of the selected item.
|
||||
this.lightboxItem = { ...item };
|
||||
},
|
||||
closeLightboxModal() { // Closes the editing modal and discards the edited copy of the item.
|
||||
this.lightboxItem = null;
|
||||
},
|
||||
openEditingModalWith(item) {
|
||||
this.editingItem = item;
|
||||
},
|
||||
closeEditingModal() {
|
||||
this.editingItem = null;
|
||||
},
|
||||
saveEditingItem() { // Saves the edited copy of the item.
|
||||
this.$store.dispatch('updateItem', this.editingItem);
|
||||
this.closeEditingModal();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
8790
web/yarn.lock
Normal file
8790
web/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue