parent
3d01aed1c5
commit
7e6ac7583d
21 changed files with 927 additions and 38 deletions
frontend/src
|
@ -3,8 +3,13 @@
|
||||||
<Header :user="user" />
|
<Header :user="user" />
|
||||||
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<Loading v-if="loading" />
|
<div class="loading-container" v-if="loading">
|
||||||
<RouterView />
|
<Loading />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="view-container" v-else>
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Messages />
|
<Messages />
|
||||||
|
@ -130,12 +135,6 @@ export default {
|
||||||
&.right {
|
&.right {
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-text {
|
|
||||||
@include mobile {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.spacer {
|
.spacer {
|
||||||
|
@ -151,5 +150,23 @@ export default {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -42,8 +42,8 @@
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
import { type Optional } from '../models/Types';
|
import { type Optional } from '../models/Types';
|
||||||
import Dropdown from './Dropdown.vue';
|
import Dropdown from '../elements/Dropdown.vue';
|
||||||
import DropdownItem from './DropdownItem.vue';
|
import DropdownItem from '../elements/DropdownItem.vue';
|
||||||
import User from '../models/User';
|
import User from '../models/User';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -86,6 +86,8 @@ header {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
|
|
||||||
:deep(a) {
|
:deep(a) {
|
||||||
|
color: var(--color-accent);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: none;
|
background: none;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
@ -109,7 +111,7 @@ header {
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-text {
|
.logout-text {
|
||||||
@include mobile {
|
@include media(mobile) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
<div class="key">From</div>
|
<div class="key">From</div>
|
||||||
<div class="value">
|
<div class="value">
|
||||||
<a href="#" @click.prevent.stop="onStartDateClick">
|
<a href="#" @click.prevent.stop="onStartDateClick">
|
||||||
{{ displayDate(oldestPoint.timestamp) }}
|
{{ formatDate(oldestPoint.timestamp) }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -16,7 +16,7 @@
|
||||||
<div class="key">To</div>
|
<div class="key">To</div>
|
||||||
<div class="value">
|
<div class="value">
|
||||||
<a href="#" @click.prevent.stop="onEndDateClick">
|
<a href="#" @click.prevent.stop="onEndDateClick">
|
||||||
{{ displayDate(newestPoint.timestamp) }}
|
{{ formatDate(newestPoint.timestamp) }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -392,7 +392,7 @@ main {
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
@include mobile {
|
@include media(mobile) {
|
||||||
bottom: 1em;
|
bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -407,8 +407,9 @@ main {
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
background-color: rgba(255, 255, 255, 0.8);
|
background: var(--color-background);
|
||||||
border-radius: 0.25em;
|
border-radius: 0.25em;
|
||||||
|
opacity: 0.8;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
|
|
|
@ -78,7 +78,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
timeString(): string | null {
|
timeString(): string | null {
|
||||||
return this.displayDate(this.point?.timestamp)
|
return this.formatDate(this.point?.timestamp)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
292
frontend/src/components/devices/Device.vue
Normal file
292
frontend/src/components/devices/Device.vue
Normal file
|
@ -0,0 +1,292 @@
|
||||||
|
<template>
|
||||||
|
<div class="device" @click="showDetails = !showDetails">
|
||||||
|
<div class="loading-container" v-if="loading">
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header" :class="{ expanded: showDetails }">
|
||||||
|
<h2>
|
||||||
|
{{ device.name }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="buttons wide" @click.stop>
|
||||||
|
<button type="button" title="Edit" @click="showForm = true">
|
||||||
|
<font-awesome-icon icon="edit" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" title="Delete" @click="showDeleteDialog = true">
|
||||||
|
<font-awesome-icon icon="trash" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" title="Details" @click="showDetails = !showDetails">
|
||||||
|
<font-awesome-icon icon="info-circle" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="buttons mobile" @click.stop>
|
||||||
|
<Dropdown>
|
||||||
|
<template #button>
|
||||||
|
<button class="options" title="Options">
|
||||||
|
<font-awesome-icon icon="ellipsis-h" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<DropdownItem @click="showForm = true">
|
||||||
|
<template #icon>
|
||||||
|
<font-awesome-icon icon="edit" />
|
||||||
|
</template>
|
||||||
|
Edit
|
||||||
|
</DropdownItem>
|
||||||
|
|
||||||
|
<DropdownItem @click="showDeleteDialog = true">
|
||||||
|
<template #icon>
|
||||||
|
<font-awesome-icon icon="trash" />
|
||||||
|
</template>
|
||||||
|
Delete
|
||||||
|
</DropdownItem>
|
||||||
|
|
||||||
|
<DropdownItem @click="showDetails = !showDetails">
|
||||||
|
<template #icon>
|
||||||
|
<font-awesome-icon icon="info-circle" />
|
||||||
|
</template>
|
||||||
|
Details
|
||||||
|
</DropdownItem>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="details" v-if="showDetails" @click.stop>
|
||||||
|
<div class="row">
|
||||||
|
<div class="property">
|
||||||
|
Device ID
|
||||||
|
</div>
|
||||||
|
<div class="value">
|
||||||
|
{{ device.id }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row creation-date">
|
||||||
|
<div class="property">
|
||||||
|
Creation Date
|
||||||
|
</div>
|
||||||
|
<div class="value">
|
||||||
|
{{ formatDate(device.createdAt) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal :visible="true" @close="clearForm" v-if="showForm">
|
||||||
|
<template v-slot:title>
|
||||||
|
Update device
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<DeviceForm
|
||||||
|
:device="device"
|
||||||
|
@close="clearForm"
|
||||||
|
@input="onUpdate" />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:visible="showDeleteDialog"
|
||||||
|
:disabled="loading"
|
||||||
|
v-if="showDeleteDialog"
|
||||||
|
@close="showDeleteDialog = false"
|
||||||
|
@confirm="runDelete">
|
||||||
|
<template #title>
|
||||||
|
Delete device
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Are you sure you want to delete the device <b>{{ device.name }}</b>?<br />
|
||||||
|
This action cannot be undone and all the data associated with this device will be lost.
|
||||||
|
</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Api from '../../mixins/Api.vue';
|
||||||
|
import ConfirmDialog from '../../elements/ConfirmDialog.vue';
|
||||||
|
import Dates from '../../mixins/Dates.vue';
|
||||||
|
import DeviceForm from '../../components/devices/DeviceForm.vue';
|
||||||
|
import Dropdown from '../../elements/Dropdown.vue';
|
||||||
|
import DropdownItem from '../../elements/DropdownItem.vue';
|
||||||
|
import Loading from '../../elements/Loading.vue';
|
||||||
|
import Modal from '../../elements/Modal.vue';
|
||||||
|
import UserDevice from '../../models/UserDevice';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
emits: ['delete', 'update'],
|
||||||
|
mixins: [
|
||||||
|
Api,
|
||||||
|
Dates,
|
||||||
|
],
|
||||||
|
|
||||||
|
components: {
|
||||||
|
ConfirmDialog,
|
||||||
|
DeviceForm,
|
||||||
|
Dropdown,
|
||||||
|
DropdownItem,
|
||||||
|
Loading,
|
||||||
|
Modal,
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
device: {
|
||||||
|
type: Object as () => UserDevice,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
showDeleteDialog: false,
|
||||||
|
showDetails: false,
|
||||||
|
showForm: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async runDelete() {
|
||||||
|
this.showDeleteDialog = false;
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.deleteDevice(this.device.id);
|
||||||
|
this.$emit('delete', this.device);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearForm() {
|
||||||
|
this.showForm = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdate(device: UserDevice) {
|
||||||
|
this.clearForm();
|
||||||
|
this.$emit('update', device);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/common.scss" as *;
|
||||||
|
|
||||||
|
.device {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
@include media(tablet) {
|
||||||
|
min-width: 30em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5em 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
h2 {
|
||||||
|
color: var(--color-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.expanded {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0.5em;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
padding: 0.5em 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
&.mobile {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.wide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media(tablet) {
|
||||||
|
&.mobile {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.wide {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(button) {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1em;
|
||||||
|
opacity: 0.75;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
cursor: initial;
|
||||||
|
animation: fade-in 0.5s;
|
||||||
|
padding: 0.5em 0;
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
@include media(tablet) {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.property {
|
||||||
|
color: var(--color-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.property {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
|
||||||
|
@include media(tablet) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
118
frontend/src/components/devices/DeviceForm.vue
Normal file
118
frontend/src/components/devices/DeviceForm.vue
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="sync" @input.stop.prevent>
|
||||||
|
<div class="loading-container" v-if="loading">
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label for="name">
|
||||||
|
Enter a name for your device.
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input type="text"
|
||||||
|
ref="name"
|
||||||
|
placeholder="Device name"
|
||||||
|
:value="device?.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row buttons">
|
||||||
|
<button type="submit">
|
||||||
|
{{ device ? 'Update' : 'Register' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" @click="$emit('close')">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Optional } from '../../models/Types';
|
||||||
|
import Api from '../../mixins/Api.vue';
|
||||||
|
import Loading from '../../elements/Loading.vue';
|
||||||
|
import Notifications from '../../mixins/Notifications.vue';
|
||||||
|
import UserDevice from '../../models/UserDevice';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
emits: ['close', 'input'],
|
||||||
|
mixins: [
|
||||||
|
Api,
|
||||||
|
Notifications,
|
||||||
|
],
|
||||||
|
|
||||||
|
components: {
|
||||||
|
Loading,
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
device: {
|
||||||
|
type: Object as () => Optional<UserDevice>,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
devices: [] as UserDevice[],
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async sync() {
|
||||||
|
const name = this.$refs.name.value.trim();
|
||||||
|
if (!name?.length) {
|
||||||
|
this.notify({
|
||||||
|
content: 'Please enter a name for your device.',
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const device = this.device
|
||||||
|
? await this.updateDevice({
|
||||||
|
...this.device,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
: await this.registerDevice(name);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('input', device);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
this.$refs.name.focus();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1em;
|
||||||
|
|
||||||
|
button {
|
||||||
|
height: 2.3em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
66
frontend/src/components/devices/DevicesList.vue
Normal file
66
frontend/src/components/devices/DevicesList.vue
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
<template>
|
||||||
|
<div class="devices-list">
|
||||||
|
<h1>
|
||||||
|
<font-awesome-icon icon="mobile-alt" />
|
||||||
|
My devices
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li v-for="device in devices" :key="device.id">
|
||||||
|
<Device :device="device"
|
||||||
|
@delete="$emit('delete', device)"
|
||||||
|
@update="$emit('update', $event)" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Device from './Device.vue';
|
||||||
|
import UserDevice from '../../models/UserDevice';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
emits: ['delete', 'update'],
|
||||||
|
components: {
|
||||||
|
Device,
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
devices: {
|
||||||
|
type: Array as () => UserDevice[],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use '@/styles/common.scss' as *;
|
||||||
|
|
||||||
|
.devices-list {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
@include media(tablet) {
|
||||||
|
min-width: 30em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -326,7 +326,7 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@use "@/styles/common.scss";
|
@use "@/styles/common.scss" as *;
|
||||||
|
|
||||||
.filter-view {
|
.filter-view {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -341,27 +341,28 @@ export default {
|
||||||
margin-bottom: 0.25em;
|
margin-bottom: 0.25em;
|
||||||
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.66);
|
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.66);
|
||||||
|
|
||||||
@include common.tablet {
|
@include media(mobile) {
|
||||||
min-width: 45em;
|
width: calc(100vw - 2em);
|
||||||
}
|
}
|
||||||
|
|
||||||
@include common.desktop {
|
@include media(tablet) {
|
||||||
|
width: 100%;
|
||||||
min-width: 45em;
|
min-width: 45em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include common.mobile {
|
|
||||||
width: 100vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-selectors {
|
.date-selectors {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@include common.mobile {
|
@include media(mobile) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include media(tablet) {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
.date-selector {
|
.date-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
43
frontend/src/elements/ConfirmDialog.vue
Normal file
43
frontend/src/elements/ConfirmDialog.vue
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
<template>
|
||||||
|
<Modal :visible="visible" @close="$emit('close')">
|
||||||
|
<template #title>
|
||||||
|
<slot name="title" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<form class="confirm-dialog" @submit.prevent="$emit('confirm')">
|
||||||
|
<div class="wrapper">
|
||||||
|
<div class="content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button type="submit" @click="$emit('confirm')" :disabled="disabled">Confirm</button>
|
||||||
|
<button type="button" @click="$emit('close')" :disabled="disabled">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Modal from './Modal.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
emits: ['close', 'confirm'],
|
||||||
|
components: {
|
||||||
|
Modal,
|
||||||
|
},
|
||||||
|
mixins: [
|
||||||
|
Modal,
|
||||||
|
],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
|
@ -4,7 +4,7 @@
|
||||||
<slot name="button" />
|
<slot name="button" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="dropdown__container" ref="container">
|
<div class="dropdown__container" ref="container" @click="hide">
|
||||||
<div class="dropdown__content">
|
<div class="dropdown__content">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
@ -21,6 +21,10 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
hide() {
|
||||||
|
this.container.classList.remove('show');
|
||||||
|
},
|
||||||
|
|
||||||
show() {
|
show() {
|
||||||
this.container.classList.add('show');
|
this.container.classList.add('show');
|
||||||
},
|
},
|
||||||
|
@ -39,6 +43,7 @@ export default {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&:hover {
|
&:hover {
|
|
@ -32,7 +32,7 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
margin-right: 0.5rem;
|
margin: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__text {
|
&__text {
|
51
frontend/src/elements/FloatingButton.vue
Normal file
51
frontend/src/elements/FloatingButton.vue
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<template>
|
||||||
|
<button class="floating-button"
|
||||||
|
@click="$emit('click')"
|
||||||
|
:title="title"
|
||||||
|
:aria-label="title">
|
||||||
|
<font-awesome-icon :icon="icon" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
emits: ['click'],
|
||||||
|
props: {
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
button.floating-button {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1em;
|
||||||
|
right: 1em;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-background);
|
||||||
|
width: 4em;
|
||||||
|
height: 4em;
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 1.5em;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 1px 1px 2px 2px var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-accent) !important;
|
||||||
|
color: var(--color-background) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
88
frontend/src/elements/Modal.vue
Normal file
88
frontend/src/elements/Modal.vue
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
<template>
|
||||||
|
<div class="modal-container" @click="close" v-if="visible">
|
||||||
|
<div class="modal" @click.stop>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1>
|
||||||
|
<slot name="title" />
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<button class="close" title="Close" @click="close">
|
||||||
|
<font-awesome-icon icon="times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
emits: ['close'],
|
||||||
|
props: {
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
close() {
|
||||||
|
this.$emit('close');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.modal-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
min-width: 30em;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
box-shadow: 0 0 1em rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fade-in 0.5s;
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25em 1em;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
background: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25em 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.5em;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1em;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,11 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Auth from './api/Auth.vue'
|
import Auth from './api/Auth.vue'
|
||||||
|
import Devices from './api/Devices.vue'
|
||||||
import GPSData from './api/GPSData.vue'
|
import GPSData from './api/GPSData.vue'
|
||||||
import Users from './api/Users.vue'
|
import Users from './api/Users.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [
|
mixins: [
|
||||||
Auth,
|
Auth,
|
||||||
|
Devices,
|
||||||
GPSData,
|
GPSData,
|
||||||
Users,
|
Users,
|
||||||
],
|
],
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export default {
|
export default {
|
||||||
methods: {
|
methods: {
|
||||||
displayDate(date: Date | number | string | null | undefined): string {
|
formatDate(date: Date | number | string | null | undefined): string {
|
||||||
if (!date) {
|
if (!date) {
|
||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
|
|
9
frontend/src/mixins/Notifications.vue
Normal file
9
frontend/src/mixins/Notifications.vue
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
methods: {
|
||||||
|
notify(payload: any) {
|
||||||
|
this.$msgBus.emit('message', payload);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
41
frontend/src/mixins/api/Devices.vue
Normal file
41
frontend/src/mixins/api/Devices.vue
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import UserDevice from '../../models/UserDevice';
|
||||||
|
import Common from './Common.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [Common],
|
||||||
|
methods: {
|
||||||
|
async getMyDevices(): Promise<UserDevice[]> {
|
||||||
|
return (
|
||||||
|
await this.request(`/devices`) as {
|
||||||
|
devices: UserDevice[]
|
||||||
|
}
|
||||||
|
).devices;
|
||||||
|
},
|
||||||
|
|
||||||
|
async registerDevice(name: string): Promise<UserDevice> {
|
||||||
|
return await this.request(`/devices`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name },
|
||||||
|
}) as UserDevice;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateDevice(device: UserDevice): Promise<UserDevice> {
|
||||||
|
return await this.request(`/devices/${device.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: Object.keys(device).reduce((acc, key) => {
|
||||||
|
if (!['id', 'userId', 'createdAt', 'updatedAt'].includes(key)) {
|
||||||
|
acc[key] = device[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, unknown>),
|
||||||
|
}) as UserDevice;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteDevice(id: string): Promise<void> {
|
||||||
|
await this.request(`/devices/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
27
frontend/src/models/UserDevice.ts
Normal file
27
frontend/src/models/UserDevice.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import type { Optional } from "./Types";
|
||||||
|
|
||||||
|
class UserDevice {
|
||||||
|
public id: string;
|
||||||
|
public userId: number;
|
||||||
|
public name: string;
|
||||||
|
public createdAt?: Optional<Date>;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
createdAt = null,
|
||||||
|
}: {
|
||||||
|
id: string,
|
||||||
|
userId: number,
|
||||||
|
name: string,
|
||||||
|
createdAt?: Optional<Date>,
|
||||||
|
}) {
|
||||||
|
this.id = id;
|
||||||
|
this.userId = userId;
|
||||||
|
this.name = name;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserDevice;
|
|
@ -1,4 +1,5 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import Devices from '../views/Devices.vue'
|
||||||
import HomeView from '../views/HomeView.vue'
|
import HomeView from '../views/HomeView.vue'
|
||||||
import Login from '../views/Login.vue'
|
import Login from '../views/Login.vue'
|
||||||
import Logout from '../views/Logout.vue'
|
import Logout from '../views/Logout.vue'
|
||||||
|
@ -12,6 +13,12 @@ const router = createRouter({
|
||||||
component: HomeView,
|
component: HomeView,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: '/devices',
|
||||||
|
name: 'devices',
|
||||||
|
component: Devices,
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
|
@ -23,15 +30,6 @@ const router = createRouter({
|
||||||
name: 'logout',
|
name: 'logout',
|
||||||
component: Logout,
|
component: Logout,
|
||||||
},
|
},
|
||||||
|
|
||||||
//{
|
|
||||||
// path: '/about',
|
|
||||||
// name: 'about',
|
|
||||||
// // route level code-splitting
|
|
||||||
// // this generates a separate chunk (About.[hash].js) for this route
|
|
||||||
// // which is lazy-loaded when the route is visited.
|
|
||||||
// component: () => import('../views/AboutView.vue'),
|
|
||||||
//},
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
128
frontend/src/views/Devices.vue
Normal file
128
frontend/src/views/Devices.vue
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
<template>
|
||||||
|
<div class="devices">
|
||||||
|
<div class="wrapper">
|
||||||
|
<div class="loading-container" v-if="loading">
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DevicesList :devices="devices"
|
||||||
|
@delete="onDelete"
|
||||||
|
@update="onUpdate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal :visible="showDeviceForm" @close="clearForm">
|
||||||
|
<template v-slot:title>
|
||||||
|
Register a new device
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<DeviceForm
|
||||||
|
@close="clearForm"
|
||||||
|
@input="addDevice" />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<FloatingButton
|
||||||
|
icon="fas fa-plus"
|
||||||
|
title="Register a new device"
|
||||||
|
@click="showDeviceForm = true" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import DeviceForm from '../components/devices/DeviceForm.vue';
|
||||||
|
import Api from '../mixins/Api.vue';
|
||||||
|
import DevicesList from '../components/devices/DevicesList.vue';
|
||||||
|
import FloatingButton from '../elements/FloatingButton.vue';
|
||||||
|
import Loading from '../elements/Loading.vue';
|
||||||
|
import Modal from '../elements/Modal.vue';
|
||||||
|
import UserDevice from '../models/UserDevice';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [Api],
|
||||||
|
components: {
|
||||||
|
DeviceForm,
|
||||||
|
DevicesList,
|
||||||
|
FloatingButton,
|
||||||
|
Loading,
|
||||||
|
Modal,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
devices: [] as UserDevice[],
|
||||||
|
loading: false,
|
||||||
|
showDeviceForm: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
deviceIndexById() {
|
||||||
|
return this.devices.reduce(
|
||||||
|
(acc: Record<string, number>, device: UserDevice, index: number) => {
|
||||||
|
acc[device.id] = index;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
addDevice(device: UserDevice) {
|
||||||
|
this.devices.push(device);
|
||||||
|
this.clearForm();
|
||||||
|
},
|
||||||
|
|
||||||
|
clearForm() {
|
||||||
|
this.showDeviceForm = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
onDelete(device: UserDevice) {
|
||||||
|
const index = this.deviceIndexById[device.id];
|
||||||
|
this.devices.splice(index, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdate(device: UserDevice) {
|
||||||
|
const index = this.deviceIndexById[device.id];
|
||||||
|
this.devices[index] = device;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
this.devices = await this.getMyDevices();
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use '@/styles/common.scss' as *;
|
||||||
|
|
||||||
|
.devices {
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2em;
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
border-radius: 1em;
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.2);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
@include media(mobile) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media(tablet) {
|
||||||
|
min-width: 30em;
|
||||||
|
max-width: 40em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -105,7 +105,7 @@ main {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--vt-c-green-bg-light);
|
background: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
|
|
Loading…
Add table
Reference in a new issue