diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b7a3b21..3a144cc 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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> diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index 33fd443..0937729 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -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; } } diff --git a/frontend/src/components/Map.vue b/frontend/src/components/Map.vue index 84cfd08..33acddb 100644 --- a/frontend/src/components/Map.vue +++ b/frontend/src/components/Map.vue @@ -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 { diff --git a/frontend/src/components/PointInfo.vue b/frontend/src/components/PointInfo.vue index 7b1bb85..4c535d7 100644 --- a/frontend/src/components/PointInfo.vue +++ b/frontend/src/components/PointInfo.vue @@ -78,7 +78,7 @@ export default { }, timeString(): string | null { - return this.displayDate(this.point?.timestamp) + return this.formatDate(this.point?.timestamp) }, }, diff --git a/frontend/src/components/devices/Device.vue b/frontend/src/components/devices/Device.vue new file mode 100644 index 0000000..557a039 --- /dev/null +++ b/frontend/src/components/devices/Device.vue @@ -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> diff --git a/frontend/src/components/devices/DeviceForm.vue b/frontend/src/components/devices/DeviceForm.vue new file mode 100644 index 0000000..b771f0f --- /dev/null +++ b/frontend/src/components/devices/DeviceForm.vue @@ -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> diff --git a/frontend/src/components/devices/DevicesList.vue b/frontend/src/components/devices/DevicesList.vue new file mode 100644 index 0000000..89a646e --- /dev/null +++ b/frontend/src/components/devices/DevicesList.vue @@ -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> diff --git a/frontend/src/components/filter/Form.vue b/frontend/src/components/filter/Form.vue index 052b2ed..8a8a850 100644 --- a/frontend/src/components/filter/Form.vue +++ b/frontend/src/components/filter/Form.vue @@ -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; diff --git a/frontend/src/elements/ConfirmDialog.vue b/frontend/src/elements/ConfirmDialog.vue new file mode 100644 index 0000000..dbb88f9 --- /dev/null +++ b/frontend/src/elements/ConfirmDialog.vue @@ -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> diff --git a/frontend/src/components/Dropdown.vue b/frontend/src/elements/Dropdown.vue similarity index 90% rename from frontend/src/components/Dropdown.vue rename to frontend/src/elements/Dropdown.vue index ce0c100..856f913 100644 --- a/frontend/src/components/Dropdown.vue +++ b/frontend/src/elements/Dropdown.vue @@ -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 { diff --git a/frontend/src/components/DropdownItem.vue b/frontend/src/elements/DropdownItem.vue similarity index 96% rename from frontend/src/components/DropdownItem.vue rename to frontend/src/elements/DropdownItem.vue index 0a68eb8..9d881ea 100644 --- a/frontend/src/components/DropdownItem.vue +++ b/frontend/src/elements/DropdownItem.vue @@ -32,7 +32,7 @@ export default { } &__icon { - margin-right: 0.5rem; + margin: 0 0.5rem; } &__text { diff --git a/frontend/src/elements/FloatingButton.vue b/frontend/src/elements/FloatingButton.vue new file mode 100644 index 0000000..e85491d --- /dev/null +++ b/frontend/src/elements/FloatingButton.vue @@ -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> diff --git a/frontend/src/elements/Modal.vue b/frontend/src/elements/Modal.vue new file mode 100644 index 0000000..05667d7 --- /dev/null +++ b/frontend/src/elements/Modal.vue @@ -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> diff --git a/frontend/src/mixins/Api.vue b/frontend/src/mixins/Api.vue index f20615e..84a0654 100644 --- a/frontend/src/mixins/Api.vue +++ b/frontend/src/mixins/Api.vue @@ -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, ], diff --git a/frontend/src/mixins/Dates.vue b/frontend/src/mixins/Dates.vue index 089b3e9..60bae27 100644 --- a/frontend/src/mixins/Dates.vue +++ b/frontend/src/mixins/Dates.vue @@ -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 '-' } diff --git a/frontend/src/mixins/Notifications.vue b/frontend/src/mixins/Notifications.vue new file mode 100644 index 0000000..2060db9 --- /dev/null +++ b/frontend/src/mixins/Notifications.vue @@ -0,0 +1,9 @@ +<script lang="ts"> +export default { + methods: { + notify(payload: any) { + this.$msgBus.emit('message', payload); + }, + }, +} +</script> diff --git a/frontend/src/mixins/api/Devices.vue b/frontend/src/mixins/api/Devices.vue new file mode 100644 index 0000000..f138b4d --- /dev/null +++ b/frontend/src/mixins/api/Devices.vue @@ -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> diff --git a/frontend/src/models/UserDevice.ts b/frontend/src/models/UserDevice.ts new file mode 100644 index 0000000..eb9376d --- /dev/null +++ b/frontend/src/models/UserDevice.ts @@ -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; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index fc50124..bae97bd 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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'), - //}, ], }) diff --git a/frontend/src/views/Devices.vue b/frontend/src/views/Devices.vue new file mode 100644 index 0000000..07dd48b --- /dev/null +++ b/frontend/src/views/Devices.vue @@ -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> diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 15b7115..9a1d469 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -105,7 +105,7 @@ main { justify-content: center; height: 100%; width: 100%; - background: var(--vt-c-green-bg-light); + background: var(--color-accent); } form {