parent
c4e0c67e34
commit
99de342af3
28 changed files with 952 additions and 71 deletions
frontend
src
45
frontend/package-lock.json
generated
45
frontend/package-lock.json
generated
|
@ -13,10 +13,12 @@
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.8",
|
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||||
|
"@vueuse/core": "^12.8.2",
|
||||||
"chart.js": "^4.4.8",
|
"chart.js": "^4.4.8",
|
||||||
"chartjs-adapter-date-fns": "^3.0.0",
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
"countries-list": "^3.1.1",
|
"countries-list": "^3.1.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
"ol": "^10.4.0",
|
"ol": "^10.4.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-chartjs": "^5.3.2",
|
"vue-chartjs": "^5.3.2",
|
||||||
|
@ -1767,6 +1769,12 @@
|
||||||
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-bluetooth": {
|
||||||
|
"version": "0.0.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||||
|
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.24.1",
|
"version": "8.24.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz",
|
||||||
|
@ -2319,6 +2327,42 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vueuse/core": {
|
||||||
|
"version": "12.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz",
|
||||||
|
"integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/web-bluetooth": "^0.0.21",
|
||||||
|
"@vueuse/metadata": "12.8.2",
|
||||||
|
"@vueuse/shared": "12.8.2",
|
||||||
|
"vue": "^3.5.13"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vueuse/metadata": {
|
||||||
|
"version": "12.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz",
|
||||||
|
"integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vueuse/shared": {
|
||||||
|
"version": "12.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz",
|
||||||
|
"integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.13"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.14.0",
|
"version": "8.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
|
||||||
|
@ -3937,7 +3981,6 @@
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/mrmime": {
|
"node_modules/mrmime": {
|
||||||
|
|
|
@ -18,10 +18,12 @@
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.8",
|
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||||
|
"@vueuse/core": "^12.8.2",
|
||||||
"chart.js": "^4.4.8",
|
"chart.js": "^4.4.8",
|
||||||
"chartjs-adapter-date-fns": "^3.0.0",
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
"countries-list": "^3.1.1",
|
"countries-list": "^3.1.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
"ol": "^10.4.0",
|
"ol": "^10.4.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-chartjs": "^5.3.2",
|
"vue-chartjs": "^5.3.2",
|
||||||
|
|
|
@ -1,24 +1,169 @@
|
||||||
<template>
|
<template>
|
||||||
<!--
|
<div class="app-container">
|
||||||
<header>
|
<header v-if="user">
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<nav>
|
<nav>
|
||||||
<RouterLink to="/">Home</RouterLink>
|
<li class="main">
|
||||||
</nav>
|
<RouterLink to="/">
|
||||||
</div>
|
<font-awesome-icon icon="map-marker-alt" /> GPSTracker
|
||||||
</header>
|
</RouterLink>
|
||||||
-->
|
</li>
|
||||||
|
|
||||||
<RouterView />
|
<div class="spacer" />
|
||||||
|
|
||||||
|
<li class="right">
|
||||||
|
<RouterLink to="/logout">
|
||||||
|
<font-awesome-icon icon="sign-out-alt" />
|
||||||
|
<span class="logout-text">Logout</span>
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Messages />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { RouterLink, RouterView } from 'vue-router'
|
||||||
|
|
||||||
|
import { type Optional } from './models/Types';
|
||||||
|
import Api from './mixins/Api.vue';
|
||||||
|
import Loading from './elements/Loading.vue';
|
||||||
|
import Messages from './components/Messages.vue'
|
||||||
|
import User from './models/User';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
mixins: [Api],
|
||||||
components: {
|
components: {
|
||||||
|
Loading,
|
||||||
|
Messages,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
RouterView,
|
RouterView,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
user: null as Optional<User>,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = await this.fetchUser()
|
||||||
|
const currentRedirect = this.$route.query.redirect
|
||||||
|
this.user = auth?.user
|
||||||
|
|
||||||
|
if (auth) {
|
||||||
|
if (currentRedirect?.length) {
|
||||||
|
this.$router.push(currentRedirect as string)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let redirect = '/login'
|
||||||
|
if (currentRedirect?.length && currentRedirect !== '/login') {
|
||||||
|
redirect += `?redirect=${currentRedirect}`
|
||||||
|
} else if (this.$route.path !== '/login' && this.$route.path !== '/logout') {
|
||||||
|
redirect += `?redirect=${this.$route.path}${this.$route.hash}`
|
||||||
|
} else {
|
||||||
|
redirect += '?redirect=/'
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$router.push(redirect)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use "@/styles/common.scss" as *;
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/common.scss" as *;
|
||||||
|
|
||||||
|
$header-height: 3rem;
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-background);
|
||||||
|
|
||||||
|
header {
|
||||||
|
width: 100%;
|
||||||
|
height: $header-height;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
box-shadow: 0 0 0.5rem 0 rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: inline-block;
|
||||||
|
list-style: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
|
||||||
|
&.main {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
|
||||||
|
:deep(a) {
|
||||||
|
&:hover {
|
||||||
|
background: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
margin-top: -0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.main) {
|
||||||
|
:deep(a) {
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-text {
|
||||||
|
@include mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% - #{$header-height});
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -60,6 +60,10 @@
|
||||||
--vt-c-lime-fg-dark: #27ae60;
|
--vt-c-lime-fg-dark: #27ae60;
|
||||||
--vt-c-lime-bg-light: #5cb85c;
|
--vt-c-lime-bg-light: #5cb85c;
|
||||||
--vt-c-lime-bg-dark: #449d44;
|
--vt-c-lime-bg-dark: #449d44;
|
||||||
|
--vt-c-gray-fg-light: #95a5a6;
|
||||||
|
--vt-c-gray-fg-dark: #7f8c8d;
|
||||||
|
--vt-c-gray-bg-light: #d9edf7;
|
||||||
|
--vt-c-gray-bg-dark: #bce8f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* semantic color variables for this project */
|
/* semantic color variables for this project */
|
||||||
|
@ -74,8 +78,14 @@
|
||||||
--color-heading: var(--vt-c-text-light-1);
|
--color-heading: var(--vt-c-text-light-1);
|
||||||
--color-text: var(--vt-c-text-light-1);
|
--color-text: var(--vt-c-text-light-1);
|
||||||
--color-accent: var(--vt-c-blue-fg-light);
|
--color-accent: var(--vt-c-blue-fg-light);
|
||||||
|
--color-accent-bg: var(--vt-c-blue-bg-light);
|
||||||
--color-hover: var(--vt-c-blue-fg-dark);
|
--color-hover: var(--vt-c-blue-fg-dark);
|
||||||
|
|
||||||
|
--color-error-bg: var(--vt-c-red-bg-light);
|
||||||
|
--color-error-fg: var(--vt-c-gray-fg-light);
|
||||||
|
|
||||||
|
--default-popup-bg: var(--vt-c-green-fg-light);
|
||||||
|
|
||||||
--section-gap: 160px;
|
--section-gap: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,6 +101,12 @@
|
||||||
--color-heading: var(--vt-c-text-dark-1);
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
--color-text: var(--vt-c-text-dark-2);
|
--color-text: var(--vt-c-text-dark-2);
|
||||||
--color-accent: var(--vt-c-blue-fg-dark);
|
--color-accent: var(--vt-c-blue-fg-dark);
|
||||||
|
--color-accent-bg: var(--vt-c-blue-bg-dark);
|
||||||
|
|
||||||
|
--color-error-bg: var(--vt-c-red-bg-dark);
|
||||||
|
--color-error-fg: var(--vt-c-gray-fg-dark);
|
||||||
|
|
||||||
|
--default-popup-bg: var(--vt-c-green-fg-dark);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,3 +144,4 @@ body {
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -70,7 +70,7 @@ import VectorLayer from 'ol/layer/Vector';
|
||||||
import View from 'ol/View';
|
import View from 'ol/View';
|
||||||
import { useGeographic } from 'ol/proj';
|
import { useGeographic } from 'ol/proj';
|
||||||
|
|
||||||
import type { Nullable } from '../models/Types';
|
import type { Optional } from '../models/Types';
|
||||||
import Api from '../mixins/Api.vue';
|
import Api from '../mixins/Api.vue';
|
||||||
import Dates from '../mixins/Dates.vue';
|
import Dates from '../mixins/Dates.vue';
|
||||||
import FilterButton from './filter/ToggleButton.vue';
|
import FilterButton from './filter/ToggleButton.vue';
|
||||||
|
@ -108,13 +108,13 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: false,
|
loading: false,
|
||||||
map: null as Nullable<Map>,
|
map: null as Optional<Map>,
|
||||||
mapView: null as Nullable<View>,
|
mapView: null as Optional<View>,
|
||||||
pointsLayer: null as Nullable<VectorLayer>,
|
pointsLayer: null as Optional<VectorLayer>,
|
||||||
popup: null as Nullable<Overlay>,
|
popup: null as Optional<Overlay>,
|
||||||
queryInitialized: false,
|
queryInitialized: false,
|
||||||
routesLayer: null as Nullable<VectorLayer>,
|
routesLayer: null as Optional<VectorLayer>,
|
||||||
selectedPoint: null as Nullable<GPSPoint>,
|
selectedPoint: null as Optional<GPSPoint>,
|
||||||
showControls: false,
|
showControls: false,
|
||||||
showMetrics: new TimelineMetricsConfiguration(),
|
showMetrics: new TimelineMetricsConfiguration(),
|
||||||
}
|
}
|
||||||
|
@ -361,13 +361,7 @@ export default {
|
||||||
@use "@/styles/common.scss" as *;
|
@use "@/styles/common.scss" as *;
|
||||||
@import "ol/ol.css";
|
@import "ol/ol.css";
|
||||||
|
|
||||||
$timeline-height: 10em;
|
$timeline-height: 10rem;
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
main {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
102
frontend/src/components/Messages.vue
Normal file
102
frontend/src/components/Messages.vue
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
<template>
|
||||||
|
<div class="messages">
|
||||||
|
<div class="message"
|
||||||
|
:class="messageClasses[message.id]"
|
||||||
|
v-for="message in messages"
|
||||||
|
:key="message.id">
|
||||||
|
<PopupMessage :icon="message.icon"
|
||||||
|
:isError="message.isError"
|
||||||
|
@click="onClick(message.id)">
|
||||||
|
{{ message.content }}
|
||||||
|
</PopupMessage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Message from '../models/Message';
|
||||||
|
import PopupMessage from '../elements/PopupMessage.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
PopupMessage,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
messageClasses: {} as { [key: string]: string },
|
||||||
|
messages: [] as Message[],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
messageIndexById(): { [key: string]: number } {
|
||||||
|
return this.messages.reduce(
|
||||||
|
(acc: { [key: string]: number }, message: Message, index: number) => {
|
||||||
|
acc[message.id] = index;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as { [key: string]: number }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
addMessage(message: any) {
|
||||||
|
if (!(message instanceof Message)) {
|
||||||
|
message = new Message(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.messages.push(message);
|
||||||
|
if (message.timeout) {
|
||||||
|
setTimeout(
|
||||||
|
() => this.removeMessage(message.id),
|
||||||
|
message.timeout
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeMessage(id: string, timeout: number = 500) {
|
||||||
|
const msg = this.messages[this.messageIndexById[id]];
|
||||||
|
this.messageClasses[id] = 'remove';
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
const index = this.messageIndexById[msg?.id]
|
||||||
|
if (index != null) {
|
||||||
|
this.messages.splice(index, 1)
|
||||||
|
}
|
||||||
|
}, timeout
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
onClick(id: string) {
|
||||||
|
const msg = this.messages[this.messageIndexById[id]];
|
||||||
|
msg.onClick && msg.onClick();
|
||||||
|
this.removeMessage(id, 0);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
// @ts-ignore
|
||||||
|
this.$msgBus.on('message', this.addMessage);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.messages {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
animation: slide-up 0.5s ease-out;
|
||||||
|
|
||||||
|
&.remove {
|
||||||
|
animation: slide-down 0.5s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -329,8 +329,8 @@ export default {
|
||||||
@use "@/styles/common.scss";
|
@use "@/styles/common.scss";
|
||||||
|
|
||||||
.filter-view {
|
.filter-view {
|
||||||
background: var(--color-background);
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
background: var(--color-background);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -341,6 +341,18 @@ 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 {
|
||||||
|
min-width: 45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include common.desktop {
|
||||||
|
min-width: 45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include common.mobile {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
.date-selectors {
|
.date-selectors {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
40
frontend/src/elements/Loading.vue
Normal file
40
frontend/src/elements/Loading.vue
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
<template>
|
||||||
|
<div class="loading">
|
||||||
|
<div class="loading__spinner" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.loading {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
&__spinner {
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
|
border: 5px solid #f3f3f3;
|
||||||
|
border-top: 5px solid #3498db;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
76
frontend/src/elements/PopupMessage.vue
Normal file
76
frontend/src/elements/PopupMessage.vue
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
<template>
|
||||||
|
<div class="popup-message" :class="{ error: isError }" @click="$emit('click')">
|
||||||
|
<div class="icon">
|
||||||
|
<font-awesome-icon :icon="iconClass" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
emits: ['click'],
|
||||||
|
props: {
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: 'info-circle',
|
||||||
|
},
|
||||||
|
|
||||||
|
isError: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
iconClass() {
|
||||||
|
const baseClass = this.isError ? 'triangle-exclamation' : this.icon;
|
||||||
|
return `fas fa-${baseClass}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.popup-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: var(--default-popup-bg);
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
box-shadow: 0 0 0.25rem 0.25rem var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
height: 100%;
|
||||||
|
margin-right: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
color: var(--vt-c-text-dark-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 0 0.5rem 0.25rem #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,9 +1,13 @@
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|
||||||
|
import mitt from 'mitt'
|
||||||
|
|
||||||
/* import the fontawesome core */
|
/* import the fontawesome core */
|
||||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||||
|
|
||||||
|
@ -17,7 +21,16 @@ import { far } from '@fortawesome/free-regular-svg-icons'
|
||||||
/* add icons to the library */
|
/* add icons to the library */
|
||||||
library.add(fas, far)
|
library.add(fas, far)
|
||||||
|
|
||||||
|
/* set up the storage */
|
||||||
|
const storage = useStorage('app-storage', {
|
||||||
|
user: null,
|
||||||
|
userSession: null,
|
||||||
|
})
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
.component('font-awesome-icon', FontAwesomeIcon)
|
|
||||||
.use(router)
|
app.component('font-awesome-icon', FontAwesomeIcon)
|
||||||
.mount('#app')
|
app.use(router)
|
||||||
|
app.config.globalProperties.$storage = storage
|
||||||
|
app.config.globalProperties.$msgBus = mitt()
|
||||||
|
app.mount('#app')
|
||||||
|
|
|
@ -1,39 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import GPSPoint from '../models/GPSPoint';
|
import Auth from './api/Auth.vue'
|
||||||
import LocationQuery from '../models/LocationQuery';
|
import GPSData from './api/GPSData.vue'
|
||||||
|
import Users from './api/Users.vue'
|
||||||
// @ts-ignore
|
|
||||||
const baseURL = __API_PATH__
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
methods: {
|
mixins: [
|
||||||
async fetchPoints(query: LocationQuery): Promise<GPSPoint[]> {
|
Auth,
|
||||||
const response = await fetch(
|
GPSData,
|
||||||
`${baseURL}/gpsdata?` + new URLSearchParams(
|
Users,
|
||||||
Object.entries(query).reduce((acc: any, [key, value]) => {
|
],
|
||||||
if (value != null && key != 'data') {
|
|
||||||
acc[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value instanceof Date) {
|
|
||||||
acc[key] = value.getTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return (await response.json())
|
|
||||||
.map((gps: any) => {
|
|
||||||
return new GPSPoint({
|
|
||||||
...gps,
|
|
||||||
// Normalize timestamp to Date object
|
|
||||||
timestamp: new Date(gps.timestamp),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
26
frontend/src/mixins/api/Auth.vue
Normal file
26
frontend/src/mixins/api/Auth.vue
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { type Optional } from '../../models/Types';
|
||||||
|
import Common from './Common.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [Common],
|
||||||
|
methods: {
|
||||||
|
async login(payload: any): Promise<Optional<string>> {
|
||||||
|
const response = await this.request(`/auth`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
|
||||||
|
return response?.session?.token;
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout(): Promise<any> {
|
||||||
|
await this.request(`/auth`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.$router.push('/login');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
69
frontend/src/mixins/api/Common.vue
Normal file
69
frontend/src/mixins/api/Common.vue
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Optional } from '../../models/Types';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const baseURL = __API_PATH__
|
||||||
|
|
||||||
|
export default {
|
||||||
|
methods: {
|
||||||
|
async request(path: string, params?: {
|
||||||
|
method?: string,
|
||||||
|
query?: Optional<Record<string, any>>,
|
||||||
|
body?: Optional<any>,
|
||||||
|
}): Promise<any> {
|
||||||
|
let { method, query, body } = params || {}
|
||||||
|
if (!method?.length) {
|
||||||
|
method = 'GET'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseURL}${path}` + (
|
||||||
|
query ? '?' + new URLSearchParams(
|
||||||
|
Object.entries(query).reduce((acc: any, [key, value]) => {
|
||||||
|
if (value != null && key != 'data') {
|
||||||
|
acc[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
acc[key] = value.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
) : ''
|
||||||
|
), {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
...(body ? { body: JSON.stringify(body) } : {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = null
|
||||||
|
try {
|
||||||
|
result = await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
result = response
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`${method} ${path}: ${response.status}: ${result?.error || response.statusText}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
const e = (error as any)?.message || error
|
||||||
|
// @ts-ignore
|
||||||
|
this.$msgBus.emit('message', {
|
||||||
|
content: e,
|
||||||
|
isError: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
25
frontend/src/mixins/api/GPSData.vue
Normal file
25
frontend/src/mixins/api/GPSData.vue
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import GPSPoint from '../../models/GPSPoint';
|
||||||
|
import LocationQuery from '../../models/LocationQuery';
|
||||||
|
import Common from './Common.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [Common],
|
||||||
|
methods: {
|
||||||
|
async fetchPoints(query: LocationQuery): Promise<GPSPoint[]> {
|
||||||
|
const points = await this.request('/gpsdata', {
|
||||||
|
query: query as Record<string, any>
|
||||||
|
}) || []
|
||||||
|
|
||||||
|
return points.map((gps: any) =>
|
||||||
|
new GPSPoint({
|
||||||
|
...gps,
|
||||||
|
// Normalize timestamp to Date object
|
||||||
|
timestamp: new Date(gps.timestamp),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
13
frontend/src/mixins/api/Users.vue
Normal file
13
frontend/src/mixins/api/Users.vue
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import AuthInfo from '../../models/AuthInfo';
|
||||||
|
import Common from './Common.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [Common],
|
||||||
|
methods: {
|
||||||
|
async fetchUser(userId: string | number = 'me'): Promise<AuthInfo> {
|
||||||
|
return (await this.request(`/users/${userId}`)) as AuthInfo;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
15
frontend/src/models/AuthInfo.ts
Normal file
15
frontend/src/models/AuthInfo.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import type { Optional } from "./Types";
|
||||||
|
import User from "./User";
|
||||||
|
import UserSession from "./UserSession";
|
||||||
|
|
||||||
|
class AuthInfo {
|
||||||
|
public user: Optional<User>;
|
||||||
|
public userSession: Optional<UserSession>;
|
||||||
|
|
||||||
|
constructor(user?: User, userSession?: UserSession) {
|
||||||
|
this.user = user || null;
|
||||||
|
this.userSession = userSession || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthInfo;
|
26
frontend/src/models/Message.ts
Normal file
26
frontend/src/models/Message.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
class Message {
|
||||||
|
public id: string;
|
||||||
|
public content: string;
|
||||||
|
public icon?: string;
|
||||||
|
public isError: boolean;
|
||||||
|
public timeout?: number;
|
||||||
|
public onClick: (...args: any[]) => void = () => {};
|
||||||
|
|
||||||
|
constructor(msg: {
|
||||||
|
id?: string;
|
||||||
|
content: string;
|
||||||
|
icon?: string;
|
||||||
|
isError?: boolean;
|
||||||
|
timeout?: number;
|
||||||
|
onClick?: (...args: any[]) => void;
|
||||||
|
}) {
|
||||||
|
this.id = msg.id || Math.random().toString(36).substring(2, 11);
|
||||||
|
this.content = msg.content;
|
||||||
|
this.icon = msg.icon;
|
||||||
|
this.isError = msg.isError || false;
|
||||||
|
this.timeout = msg.timeout !== undefined ? msg.timeout : 5000;
|
||||||
|
this.onClick = msg.onClick || this.onClick;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Message;
|
|
@ -1 +1,5 @@
|
||||||
export type Nullable<T> = T | null;
|
type Optional<T> = T | null;
|
||||||
|
|
||||||
|
export {
|
||||||
|
type Optional,
|
||||||
|
};
|
||||||
|
|
31
frontend/src/models/User.ts
Normal file
31
frontend/src/models/User.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import type { Optional } from "./Types";
|
||||||
|
|
||||||
|
class User {
|
||||||
|
public id: number;
|
||||||
|
public username: number;
|
||||||
|
public email: string;
|
||||||
|
public firstName: Optional<string>;
|
||||||
|
public lastName: Optional<string>;
|
||||||
|
public createdAt: Optional<Date>;
|
||||||
|
public updatedAt: Optional<Date>;
|
||||||
|
|
||||||
|
constructor(user: {
|
||||||
|
id: number;
|
||||||
|
username: number;
|
||||||
|
email: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
}) {
|
||||||
|
this.id = user.id;
|
||||||
|
this.username = user.username;
|
||||||
|
this.email = user.email;
|
||||||
|
this.firstName = user.firstName || null;
|
||||||
|
this.lastName = user.lastName || null;
|
||||||
|
this.createdAt = user.createdAt || null;
|
||||||
|
this.updatedAt = user.updatedAt || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default User;
|
25
frontend/src/models/UserSession.ts
Normal file
25
frontend/src/models/UserSession.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import type { Optional } from "./Types";
|
||||||
|
|
||||||
|
class UserSession {
|
||||||
|
public id: string;
|
||||||
|
public userId: number;
|
||||||
|
public createdAt: Date;
|
||||||
|
public updatedAt: Date;
|
||||||
|
public expiresAt: Optional<Date>;
|
||||||
|
|
||||||
|
constructor(userSession: {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}) {
|
||||||
|
this.id = userSession.id;
|
||||||
|
this.userId = userSession.userId;
|
||||||
|
this.createdAt = userSession.createdAt;
|
||||||
|
this.updatedAt = userSession.updatedAt;
|
||||||
|
this.expiresAt = userSession.expiresAt || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserSession;
|
|
@ -1,5 +1,7 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import HomeView from '../views/HomeView.vue'
|
import HomeView from '../views/HomeView.vue'
|
||||||
|
import Login from '../views/Login.vue'
|
||||||
|
import Logout from '../views/Logout.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
@ -9,6 +11,19 @@ const router = createRouter({
|
||||||
name: 'home',
|
name: 'home',
|
||||||
component: HomeView,
|
component: HomeView,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: Login,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: '/logout',
|
||||||
|
name: 'logout',
|
||||||
|
component: Logout,
|
||||||
|
},
|
||||||
|
|
||||||
//{
|
//{
|
||||||
// path: '/about',
|
// path: '/about',
|
||||||
// name: 'about',
|
// name: 'about',
|
||||||
|
|
|
@ -35,6 +35,7 @@ button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
color: var(--color-text);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
@ -44,8 +45,70 @@ button {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button[type=submit] {
|
[type=submit] {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: white !important;
|
||||||
margin: 0.5em;
|
margin: 0.5em;
|
||||||
font-size: 1.15em;
|
font-size: 1.15em;
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
|
transition: background-color 0.5s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
color: var(--color-background);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=text], [type=email], [type=password], textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 0.5em;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: border-color 0.5s;
|
||||||
|
transition: border-color 0.5s;
|
||||||
|
//
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5em;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-down {
|
||||||
|
from {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
123
frontend/src/views/Login.vue
Normal file
123
frontend/src/views/Login.vue
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<form @submit.prevent="onSubmit">
|
||||||
|
<h1 class="title">
|
||||||
|
<font-awesome-icon icon="map-marker-alt" />
|
||||||
|
GPSTracker
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<input type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
v-model="username"
|
||||||
|
:disabled="loading"
|
||||||
|
ref="username" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<input type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
:disabled="loading"
|
||||||
|
v-model="password" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button type="submit" :disabled="loading">
|
||||||
|
<font-awesome-icon icon="sign-in-alt" />
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Api from '../mixins/Api.vue';
|
||||||
|
import Loading from '../elements/Loading.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [Api],
|
||||||
|
components: {
|
||||||
|
Loading,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async onSubmit() {
|
||||||
|
if (this.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
const redirect = this.$route.query.redirect || '/';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionToken = await this.login({
|
||||||
|
username: this.username,
|
||||||
|
password: this.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionToken?.length) {
|
||||||
|
window.location.href = redirect as string;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// @ts-ignore
|
||||||
|
this.$msgBus.emit('message', {
|
||||||
|
content: (error as any)?.message || error,
|
||||||
|
isError: true,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
(this.$refs.username as HTMLElement).focus();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--vt-c-green-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 25em;
|
||||||
|
max-width: 80%;
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: 2px 2px 2px 2px var(--color-border);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
padding: 1em;
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
19
frontend/src/views/Logout.vue
Normal file
19
frontend/src/views/Logout.vue
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<template>
|
||||||
|
<Loading />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Api from '../mixins/Api.vue';
|
||||||
|
import Loading from '../elements/Loading.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [Api],
|
||||||
|
components: {
|
||||||
|
Loading,
|
||||||
|
},
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
await this.logout();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
|
@ -23,7 +23,7 @@ function authenticate(roles: RoleName[] = []) {
|
||||||
let session: Optional<UserSession>;
|
let session: Optional<UserSession>;
|
||||||
|
|
||||||
// Check the `session` cookie or the `Authorization` header for the session token
|
// Check the `session` cookie or the `Authorization` header for the session token
|
||||||
let token = req.cookies?.session;
|
let token = req.headers?.cookie?.match(/session=([^;]+)/)?.[1];
|
||||||
if (!token?.length) {
|
if (!token?.length) {
|
||||||
const authHeader = req.headers?.authorization;
|
const authHeader = req.headers?.authorization;
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
|
|
@ -37,17 +37,23 @@ abstract class Route {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Forbidden) {
|
if (error instanceof Forbidden) {
|
||||||
res.status(403).send(error.message);
|
res.status(403).json({
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof NotFound) {
|
if (error instanceof NotFound) {
|
||||||
res.status(404).send(error.message);
|
res.status(404).json({
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof BadRequest) {
|
if (error instanceof BadRequest) {
|
||||||
res.status(400).send(error.message);
|
res.status(400).json({
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,10 @@ abstract class ApiRoute extends Route {
|
||||||
protected static handleError(req: Request, res: Response, error: Error) {
|
protected static handleError(req: Request, res: Response, error: Error) {
|
||||||
// Handle API unauthorized errors with a 401+JSON response instead of a redirect
|
// Handle API unauthorized errors with a 401+JSON response instead of a redirect
|
||||||
if (error instanceof Unauthorized) {
|
if (error instanceof Unauthorized) {
|
||||||
res.status(401).send(error.message);
|
res.status(401).json({
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -62,7 +62,7 @@ class Auth extends ApiV1Route {
|
||||||
* If the user is authenticated, the session will be destroyed and the cookie will be cleared.
|
* If the user is authenticated, the session will be destroyed and the cookie will be cleared.
|
||||||
*/
|
*/
|
||||||
@authenticate()
|
@authenticate()
|
||||||
delete = async (_: Request, res: Response, auth: Optional<AuthInfo>) => {
|
delete = async (_: Request, res: Response, auth?: Optional<AuthInfo>) => {
|
||||||
const session = auth!.session;
|
const session = auth!.session;
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
|
|
Loading…
Add table
Reference in a new issue