Major bootstrap.

- Migrated frontend to Vue.

- Migrated frontend map to OL API.

- Extended environment variables.

- README.

- Country information/flag integration.

- Implemented generic db/repo.
This commit is contained in:
Fabio Manganiello 2025-02-22 16:31:43 +01:00
parent 03deaa9cd8
commit 5dfde74ccf
40 changed files with 7309 additions and 223 deletions

24
frontend/src/App.vue Normal file
View file

@ -0,0 +1,24 @@
<template>
<!--
<header>
<div class="wrapper">
<nav>
<RouterLink to="/">Home</RouterLink>
</nav>
</div>
</header>
-->
<RouterView />
</template>
<script lang="ts">
import { RouterLink, RouterView } from 'vue-router'
export default {
components: {
RouterLink,
RouterView,
},
}
</script>

View file

@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

(image error) Size: 276 B

View file

@ -0,0 +1,31 @@
@import './base.css';
#app {
max-width: 1280px;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: flex;
}
}

View file

@ -0,0 +1,171 @@
<template>
<main>
<div class="loading" v-if="loading">Loading...</div>
<div class="map-wrapper" v-else>
<div id="map">
<PointInfo :point="selectedPoint" ref="popup" @close="selectedPoint = null" />
</div>
</div>
</main>
</template>
<script lang="ts">
import Feature from 'ol/Feature';
import GPSPoint from '../models/GPSPoint';
import Map from 'ol/Map';
import OSM from 'ol/source/OSM';
import Overlay from 'ol/Overlay';
import Point from 'ol/geom/Point';
import PointInfo from './PointInfo.vue';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import View from 'ol/View';
import TileLayer from 'ol/layer/Tile';
import { Circle, Fill, Style } from 'ol/style';
import { useGeographic } from 'ol/proj';
import { type Nullable } from '../models/Types';
// @ts-ignore
const baseURL = __API_PATH__
useGeographic()
export default {
components: {
PointInfo,
},
data() {
return {
gpsPoints: [] as GPSPoint[],
loading: false,
map: null as Nullable<Map>,
popup: null as Nullable<Overlay>,
selectedPoint: null as Nullable<GPSPoint>,
}
},
methods: {
async fetchData() {
this.loading = true
try {
const response = await fetch(`${baseURL}/gpsdata`)
return (await response.json())
.map((gps: any) => {
return new GPSPoint(gps)
})
} catch (error) {
console.error(error)
} finally {
this.loading = false
}
},
createMap(gpsPoints: GPSPoint[]) {
const points = gpsPoints.map((gps: GPSPoint) => {
const point = new Point([gps.longitude, gps.latitude])
return point
})
const pointFeatures = points.map((point: Point) => new Feature(point))
const view = new View(this.getCenterAndZoom())
const map = new Map({
target: 'map',
layers: [
new TileLayer({
source: new OSM(),
}),
new VectorLayer({
source: new VectorSource({
features: pointFeatures,
}),
style: new Style({
image: new Circle({
radius: 5,
fill: new Fill({ color: 'red' }),
}),
}),
}),
],
view: view
})
// @ts-expect-error
this.$refs.popup.bindPopup(map)
this.bindClick(map)
return map
},
getCenterAndZoom() {
if (!this.gpsPoints?.length) {
return {
center: [0, 0],
zoom: 2,
}
}
let [minX, minY, maxX, maxY] = [Infinity, Infinity, -Infinity, -Infinity]
this.gpsPoints.forEach((gps: GPSPoint) => {
minX = Math.min(minX, gps.longitude)
minY = Math.min(minY, gps.latitude)
maxX = Math.max(maxX, gps.longitude)
maxY = Math.max(maxY, gps.latitude)
})
const center = [(minX + maxX) / 2, (minY + maxY) / 2]
const zoom = Math.max(2, Math.min(18, 18 - Math.log2(Math.max(maxX - minX, maxY - minY))))
return { center, zoom }
},
bindClick(map: Map) {
map.on('click', (event) => {
const feature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature)
if (feature) {
const point = this.gpsPoints.find((gps: GPSPoint) => {
const [longitude, latitude] = (feature.getGeometry() as any).getCoordinates()
return gps.longitude === longitude && gps.latitude === latitude
})
if (point) {
this.selectedPoint = point
// @ts-expect-error
this.$refs.popup.setPosition(event.coordinate)
}
} else {
this.selectedPoint = null
}
})
},
},
async mounted() {
this.gpsPoints = await this.fetchData()
this.map = this.createMap(this.gpsPoints)
},
}
</script>
<style lang="scss" scoped>
@import "ol/ol.css";
html,
body {
margin: 0;
height: 100%;
}
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
}
:deep(.ol-viewport) {
.ol-attribution {
position: absolute !important;
bottom: 0 !important;
right: 0 !important;
}
}
</style>

View file

@ -0,0 +1,113 @@
<template>
<div class="popup" :class="{ hidden: !point }" ref="popup">
<div class="popup-content" v-if="point">
<button @click="$emit('close')">Close</button>
<div class="point-info">
<h2 class="address" v-if="point.address">{{ point.address }}</h2>
<h2 class="latlng" v-else>{{ point.latitude }}, {{ point.longitude }}</h2>
<p class="latlng" v-if="point.address">{{ point.latitude }}, {{ point.longitude }}</p>
<p class="locality" v-if="point.locality">{{ point.locality }}</p>
<p class="postal-code" v-if="point.postalCode">{{ point.postalCode }}</p>
<p class="country" v-if="country">
<span class="flag" v-if="countryFlag">{{ countryFlag }}&nbsp; </span>
<span class="name">{{ country.name }}</span>,&nbsp;
<span class="continent">{{ country.continent }}</span>
</p>
<p class="timestamp" v-if="timeString">{{ timeString }}</p>
</div>
</div>
</div>
</template>
<script lang="ts">
import GPSPoint from '../models/GPSPoint';
import Map from 'ol/Map';
import Overlay from 'ol/Overlay';
import type { TCountryCode } from 'countries-list';
import { getCountryData, getEmojiFlag } from 'countries-list';
export default {
emit: ['close'],
props: {
point: {
type: [GPSPoint, null],
},
},
data() {
return {
popup: null as Overlay | null,
}
},
computed: {
country() {
const cc = this.point?.country as string | undefined
if (cc?.length) {
return getCountryData(cc.toUpperCase() as TCountryCode)
}
return null
},
countryFlag() {
return this.country ? getEmojiFlag(this.country.iso2 as TCountryCode) : null
},
timeString(): string | null {
return this.point?.timestamp ? new Date(this.point.timestamp).toLocaleString() : null
},
},
methods: {
bindPopup(map: Map) {
this.popup = new Overlay({
element: this.$refs.popup as HTMLElement,
autoPan: true,
})
// @ts-ignore
map.addOverlay(this.popup)
},
setPosition(coordinates: number[]) {
if (this.popup) {
this.popup.setPosition(coordinates)
}
},
},
}
</script>
<style lang="scss" scoped>
@import "ol/ol.css";
.popup {
position: absolute;
background: var(--color-background);
min-width: 20em;
padding: 1em;
border-radius: 1em;
box-shadow: 2px 2px 2px 2px var(--color-border);
&.hidden {
padding: 0;
border-radius: 0;
box-shadow: none;
width: 0;
height: 0;
min-width: 0;
pointer-events: none;
}
p.latlng {
font-size: 0.8em;
}
.timestamp {
color: var(--color-heading);
font-weight: bold;
font-size: 0.9em;
}
}
</style>

11
frontend/src/main.ts Normal file
View file

@ -0,0 +1,11 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')

View file

@ -0,0 +1,23 @@
class GPSPoint {
public latitude: number;
public longitude: number;
public altitude: number;
public address: string;
public locality: string;
public country: string;
public postalCode: string;
public timestamp: Date;
constructor(public data: any) {
this.latitude = data.latitude;
this.longitude = data.longitude;
this.altitude = data.altitude;
this.address = data.address;
this.locality = data.locality;
this.country = data.country;
this.postalCode = data.postalCode;
this.timestamp = data.timestamp;
}
}
export default GPSPoint;

View file

@ -0,0 +1 @@
export type Nullable<T> = T | null;

View file

@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
//{
// 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'),
//},
],
})
export default router

View file

@ -0,0 +1,15 @@
<script lang="ts">
import Map from '../components/Map.vue';
export default {
components: {
Map,
},
};
</script>
<template>
<main>
<Map />
</main>
</template>