Frontend support for user devices.

This commit is contained in:
Fabio Manganiello 2025-03-09 10:59:39 +01:00
parent 3d01aed1c5
commit 7e6ac7583d
Signed by: blacklight
GPG key ID: D90FBA7F76362774
21 changed files with 927 additions and 38 deletions

View file

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

View file

@ -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;
}
}

View file

@ -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 {

View file

@ -78,7 +78,7 @@ export default {
},
timeString(): string | null {
return this.displayDate(this.point?.timestamp)
return this.formatDate(this.point?.timestamp)
},
},

View 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" />&nbsp;
</button>
<button type="button" title="Delete" @click="showDeleteDialog = true">
<font-awesome-icon icon="trash" />&nbsp;
</button>
<button type="button" title="Details" @click="showDetails = !showDetails">
<font-awesome-icon icon="info-circle" />&nbsp;
</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>

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

View file

@ -0,0 +1,66 @@
<template>
<div class="devices-list">
<h1>
<font-awesome-icon icon="mobile-alt" />&nbsp;
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>

View file

@ -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;

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

View file

@ -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 {

View file

@ -32,7 +32,7 @@ export default {
}
&__icon {
margin-right: 0.5rem;
margin: 0 0.5rem;
}
&__text {

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

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

View file

@ -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,
],

View file

@ -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 '-'
}

View file

@ -0,0 +1,9 @@
<script lang="ts">
export default {
methods: {
notify(payload: any) {
this.$msgBus.emit('message', payload);
},
},
}
</script>

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

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

View file

@ -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'),
//},
],
})

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

View file

@ -105,7 +105,7 @@ main {
justify-content: center;
height: 100%;
width: 100%;
background: var(--vt-c-green-bg-light);
background: var(--color-accent);
}
form {