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-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"chart.js": "^4.4.8",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"countries-list": "^3.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mitt": "^3.0.1",
|
||||
"ol": "^10.4.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
|
@ -1767,6 +1769,12 @@
|
|||
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
||||
"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": {
|
||||
"version": "8.24.1",
|
||||
"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": {
|
||||
"version": "8.14.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
|
||||
|
@ -3937,7 +3981,6 @@
|
|||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
|
|
|
@ -18,10 +18,12 @@
|
|||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"chart.js": "^4.4.8",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"countries-list": "^3.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mitt": "^3.0.1",
|
||||
"ol": "^10.4.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
|
|
|
@ -1,24 +1,169 @@
|
|||
<template>
|
||||
<!--
|
||||
<header>
|
||||
<div class="wrapper">
|
||||
<nav>
|
||||
<RouterLink to="/">Home</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
-->
|
||||
<div class="app-container">
|
||||
<header v-if="user">
|
||||
<div class="wrapper">
|
||||
<nav>
|
||||
<li class="main">
|
||||
<RouterLink to="/">
|
||||
<font-awesome-icon icon="map-marker-alt" /> GPSTracker
|
||||
</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>
|
||||
|
||||
<script lang="ts">
|
||||
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 {
|
||||
mixins: [Api],
|
||||
components: {
|
||||
Loading,
|
||||
Messages,
|
||||
RouterLink,
|
||||
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>
|
||||
|
||||
<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-bg-light: #5cb85c;
|
||||
--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 */
|
||||
|
@ -74,8 +78,14 @@
|
|||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
--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-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;
|
||||
}
|
||||
|
||||
|
@ -91,6 +101,12 @@
|
|||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
--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;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
|
|
|
@ -70,7 +70,7 @@ import VectorLayer from 'ol/layer/Vector';
|
|||
import View from 'ol/View';
|
||||
import { useGeographic } from 'ol/proj';
|
||||
|
||||
import type { Nullable } from '../models/Types';
|
||||
import type { Optional } from '../models/Types';
|
||||
import Api from '../mixins/Api.vue';
|
||||
import Dates from '../mixins/Dates.vue';
|
||||
import FilterButton from './filter/ToggleButton.vue';
|
||||
|
@ -108,13 +108,13 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
map: null as Nullable<Map>,
|
||||
mapView: null as Nullable<View>,
|
||||
pointsLayer: null as Nullable<VectorLayer>,
|
||||
popup: null as Nullable<Overlay>,
|
||||
map: null as Optional<Map>,
|
||||
mapView: null as Optional<View>,
|
||||
pointsLayer: null as Optional<VectorLayer>,
|
||||
popup: null as Optional<Overlay>,
|
||||
queryInitialized: false,
|
||||
routesLayer: null as Nullable<VectorLayer>,
|
||||
selectedPoint: null as Nullable<GPSPoint>,
|
||||
routesLayer: null as Optional<VectorLayer>,
|
||||
selectedPoint: null as Optional<GPSPoint>,
|
||||
showControls: false,
|
||||
showMetrics: new TimelineMetricsConfiguration(),
|
||||
}
|
||||
|
@ -361,13 +361,7 @@ export default {
|
|||
@use "@/styles/common.scss" as *;
|
||||
@import "ol/ol.css";
|
||||
|
||||
$timeline-height: 10em;
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
$timeline-height: 10rem;
|
||||
|
||||
main {
|
||||
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";
|
||||
|
||||
.filter-view {
|
||||
background: var(--color-background);
|
||||
height: 100%;
|
||||
background: var(--color-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
@ -341,6 +341,18 @@ 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 common.desktop {
|
||||
min-width: 45em;
|
||||
}
|
||||
|
||||
@include common.mobile {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.date-selectors {
|
||||
display: flex;
|
||||
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 { createApp } from 'vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
import mitt from 'mitt'
|
||||
|
||||
/* import the fontawesome 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 */
|
||||
library.add(fas, far)
|
||||
|
||||
/* set up the storage */
|
||||
const storage = useStorage('app-storage', {
|
||||
user: null,
|
||||
userSession: null,
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
.component('font-awesome-icon', FontAwesomeIcon)
|
||||
.use(router)
|
||||
.mount('#app')
|
||||
|
||||
app.component('font-awesome-icon', FontAwesomeIcon)
|
||||
app.use(router)
|
||||
app.config.globalProperties.$storage = storage
|
||||
app.config.globalProperties.$msgBus = mitt()
|
||||
app.mount('#app')
|
||||
|
|
|
@ -1,39 +1,13 @@
|
|||
<script lang="ts">
|
||||
import GPSPoint from '../models/GPSPoint';
|
||||
import LocationQuery from '../models/LocationQuery';
|
||||
|
||||
// @ts-ignore
|
||||
const baseURL = __API_PATH__
|
||||
import Auth from './api/Auth.vue'
|
||||
import GPSData from './api/GPSData.vue'
|
||||
import Users from './api/Users.vue'
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
async fetchPoints(query: LocationQuery): Promise<GPSPoint[]> {
|
||||
const response = await fetch(
|
||||
`${baseURL}/gpsdata?` + 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
|
||||
}, {})
|
||||
)
|
||||
)
|
||||
|
||||
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())
|
||||
},
|
||||
},
|
||||
mixins: [
|
||||
Auth,
|
||||
GPSData,
|
||||
Users,
|
||||
],
|
||||
}
|
||||
</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 HomeView from '../views/HomeView.vue'
|
||||
import Login from '../views/Login.vue'
|
||||
import Logout from '../views/Logout.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
@ -9,6 +11,19 @@ const router = createRouter({
|
|||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: Login,
|
||||
},
|
||||
|
||||
{
|
||||
path: '/logout',
|
||||
name: 'logout',
|
||||
component: Logout,
|
||||
},
|
||||
|
||||
//{
|
||||
// path: '/about',
|
||||
// name: 'about',
|
||||
|
|
|
@ -35,6 +35,7 @@ button {
|
|||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
color: var(--color-text);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
@ -44,8 +45,70 @@ button {
|
|||
}
|
||||
}
|
||||
|
||||
button[type=submit] {
|
||||
[type=submit] {
|
||||
background: var(--color-accent);
|
||||
color: white !important;
|
||||
margin: 0.5em;
|
||||
font-size: 1.15em;
|
||||
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>;
|
||||
|
||||
// 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) {
|
||||
const authHeader = req.headers?.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
|
|
|
@ -37,17 +37,23 @@ abstract class Route {
|
|||
}
|
||||
|
||||
if (error instanceof Forbidden) {
|
||||
res.status(403).send(error.message);
|
||||
res.status(403).json({
|
||||
error: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof NotFound) {
|
||||
res.status(404).send(error.message);
|
||||
res.status(404).json({
|
||||
error: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof BadRequest) {
|
||||
res.status(400).send(error.message);
|
||||
res.status(400).json({
|
||||
error: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -13,7 +13,10 @@ abstract class ApiRoute extends Route {
|
|||
protected static handleError(req: Request, res: Response, error: Error) {
|
||||
// Handle API unauthorized errors with a 401+JSON response instead of a redirect
|
||||
if (error instanceof Unauthorized) {
|
||||
res.status(401).send(error.message);
|
||||
res.status(401).json({
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
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.
|
||||
*/
|
||||
@authenticate()
|
||||
delete = async (_: Request, res: Response, auth: Optional<AuthInfo>) => {
|
||||
delete = async (_: Request, res: Response, auth?: Optional<AuthInfo>) => {
|
||||
const session = auth!.session;
|
||||
|
||||
if (session) {
|
||||
|
|
Loading…
Add table
Reference in a new issue