parent
3d01aed1c5
commit
7e6ac7583d
21 changed files with 927 additions and 38 deletions
frontend/src
|
@ -3,8 +3,13 @@
|
|||
<Header :user="user" />
|
||||
|
||||
<div class="body">
|
||||
<Loading v-if="loading" />
|
||||
<RouterView />
|
||||
<div class="loading-container" v-if="loading">
|
||||
<Loading />
|
||||
</div>
|
||||
|
||||
<div class="view-container" v-else>
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Messages />
|
||||
|
@ -130,12 +135,6 @@ export default {
|
|||
&.right {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.logout-text {
|
||||
@include mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
|
@ -151,5 +150,23 @@ export default {
|
|||
overflow-y: auto;
|
||||
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>
|
||||
|
|
|
@ -42,8 +42,8 @@
|
|||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { type Optional } from '../models/Types';
|
||||
import Dropdown from './Dropdown.vue';
|
||||
import DropdownItem from './DropdownItem.vue';
|
||||
import Dropdown from '../elements/Dropdown.vue';
|
||||
import DropdownItem from '../elements/DropdownItem.vue';
|
||||
import User from '../models/User';
|
||||
|
||||
export default {
|
||||
|
@ -86,6 +86,8 @@ header {
|
|||
margin-left: 0.5rem;
|
||||
|
||||
:deep(a) {
|
||||
color: var(--color-accent);
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
font-size: 1.5rem;
|
||||
|
@ -109,7 +111,7 @@ header {
|
|||
}
|
||||
|
||||
.logout-text {
|
||||
@include mobile {
|
||||
@include media(mobile) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<div class="key">From</div>
|
||||
<div class="value">
|
||||
<a href="#" @click.prevent.stop="onStartDateClick">
|
||||
{{ displayDate(oldestPoint.timestamp) }}
|
||||
{{ formatDate(oldestPoint.timestamp) }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -16,7 +16,7 @@
|
|||
<div class="key">To</div>
|
||||
<div class="value">
|
||||
<a href="#" @click.prevent.stop="onEndDateClick">
|
||||
{{ displayDate(newestPoint.timestamp) }}
|
||||
{{ formatDate(newestPoint.timestamp) }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -392,7 +392,7 @@ main {
|
|||
padding: 0.5em;
|
||||
z-index: 1;
|
||||
|
||||
@include mobile {
|
||||
@include media(mobile) {
|
||||
bottom: 1em;
|
||||
}
|
||||
|
||||
|
@ -407,8 +407,9 @@ main {
|
|||
top: 0;
|
||||
right: 0;
|
||||
padding: 0.5em;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
background: var(--color-background);
|
||||
border-radius: 0.25em;
|
||||
opacity: 0.8;
|
||||
z-index: 1;
|
||||
|
||||
.row {
|
||||
|
|
|
@ -78,7 +78,7 @@ export default {
|
|||
},
|
||||
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/common.scss";
|
||||
@use "@/styles/common.scss" as *;
|
||||
|
||||
.filter-view {
|
||||
height: 100%;
|
||||
|
@ -341,27 +341,28 @@ export default {
|
|||
margin-bottom: 0.25em;
|
||||
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.66);
|
||||
|
||||
@include common.tablet {
|
||||
min-width: 45em;
|
||||
@include media(mobile) {
|
||||
width: calc(100vw - 2em);
|
||||
}
|
||||
|
||||
@include common.desktop {
|
||||
@include media(tablet) {
|
||||
width: 100%;
|
||||
min-width: 45em;
|
||||
}
|
||||
|
||||
@include common.mobile {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.date-selectors {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
@include common.mobile {
|
||||
@include media(mobile) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@include media(tablet) {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.date-selector {
|
||||
display: flex;
|
||||
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" />
|
||||
</button>
|
||||
|
||||
<div class="dropdown__container" ref="container">
|
||||
<div class="dropdown__container" ref="container" @click="hide">
|
||||
<div class="dropdown__content">
|
||||
<slot />
|
||||
</div>
|
||||
|
@ -21,6 +21,10 @@ export default {
|
|||
},
|
||||
|
||||
methods: {
|
||||
hide() {
|
||||
this.container.classList.remove('show');
|
||||
},
|
||||
|
||||
show() {
|
||||
this.container.classList.add('show');
|
||||
},
|
||||
|
@ -39,6 +43,7 @@ export default {
|
|||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
|
@ -32,7 +32,7 @@ export default {
|
|||
}
|
||||
|
||||
&__icon {
|
||||
margin-right: 0.5rem;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
&__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">
|
||||
import Auth from './api/Auth.vue'
|
||||
import Devices from './api/Devices.vue'
|
||||
import GPSData from './api/GPSData.vue'
|
||||
import Users from './api/Users.vue'
|
||||
|
||||
export default {
|
||||
mixins: [
|
||||
Auth,
|
||||
Devices,
|
||||
GPSData,
|
||||
Users,
|
||||
],
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
export default {
|
||||
methods: {
|
||||
displayDate(date: Date | number | string | null | undefined): string {
|
||||
formatDate(date: Date | number | string | null | undefined): string {
|
||||
if (!date) {
|
||||
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 Devices from '../views/Devices.vue'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import Login from '../views/Login.vue'
|
||||
import Logout from '../views/Logout.vue'
|
||||
|
@ -12,6 +13,12 @@ const router = createRouter({
|
|||
component: HomeView,
|
||||
},
|
||||
|
||||
{
|
||||
path: '/devices',
|
||||
name: 'devices',
|
||||
component: Devices,
|
||||
},
|
||||
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
|
@ -23,15 +30,6 @@ const router = createRouter({
|
|||
name: '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;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--vt-c-green-bg-light);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
form {
|
||||
|
|
Loading…
Add table
Reference in a new issue