add location logging, display last location on map
Signed-off-by: Nicolas Froger <nicolas@kektus.xyz>
This commit is contained in:
parent
4356816f07
commit
130581a411
13 changed files with 386 additions and 3 deletions
|
|
@ -0,0 +1,22 @@
|
|||
package fr.kektus.summer2024.converters;
|
||||
|
||||
import fr.kektus.summer2024.data.model.Location;
|
||||
import fr.kektus.summer2024.domain.entity.LocationEntity;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
@ApplicationScoped
|
||||
public class LocationConverter implements Converter<Location, LocationEntity> {
|
||||
@Override public Location toLeft(LocationEntity locationEntity) {
|
||||
return new Location().withId(locationEntity.id)
|
||||
.withLatitude(locationEntity.latitude)
|
||||
.withLongitude(locationEntity.longitude)
|
||||
.withTimestamp(locationEntity.timestamp);
|
||||
}
|
||||
|
||||
@Override public LocationEntity toRight(Location location) {
|
||||
return new LocationEntity().withId(location.id)
|
||||
.withLatitude(location.latitude)
|
||||
.withLongitude(location.longitude)
|
||||
.withTimestamp(location.timestamp);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package fr.kektus.summer2024.data.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.With;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
@Entity
|
||||
@AllArgsConstructor @NoArgsConstructor @With
|
||||
public class Location {
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) public Long id;
|
||||
public ZonedDateTime timestamp;
|
||||
public Float latitude;
|
||||
public Float longitude;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package fr.kektus.summer2024.data.repository;
|
||||
|
||||
import fr.kektus.summer2024.data.model.Location;
|
||||
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
@ApplicationScoped
|
||||
public class LocationRepository implements PanacheRepository<Location> {
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package fr.kektus.summer2024.domain.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.With;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
@AllArgsConstructor @NoArgsConstructor @With
|
||||
public class LocationEntity {
|
||||
public Long id;
|
||||
public ZonedDateTime timestamp;
|
||||
public Float latitude;
|
||||
public Float longitude;
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package fr.kektus.summer2024.domain.service;
|
||||
|
||||
import fr.kektus.summer2024.converters.LocationConverter;
|
||||
import fr.kektus.summer2024.data.model.Location;
|
||||
import fr.kektus.summer2024.data.repository.LocationRepository;
|
||||
import fr.kektus.summer2024.domain.entity.LocationEntity;
|
||||
import io.quarkus.panache.common.Sort;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
@ApplicationScoped
|
||||
public class LocationService {
|
||||
@Inject LocationRepository locationRepository;
|
||||
@Inject LocationConverter locationConverter;
|
||||
|
||||
@Transactional
|
||||
public LocationEntity createLocation(Float lat, Float lon) {
|
||||
final Location location = new Location().withLatitude(lat)
|
||||
.withLongitude(lon)
|
||||
.withTimestamp(ZonedDateTime.now());
|
||||
locationRepository.persist(location);
|
||||
return locationConverter.toRight(location);
|
||||
}
|
||||
|
||||
public LocationEntity getLatestLocation() {
|
||||
final Location lastLocation = locationRepository.findAll(Sort.descending("id")).firstResult();
|
||||
if (lastLocation == null)
|
||||
throw new NotFoundException();
|
||||
return locationConverter.toRight(lastLocation);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package fr.kektus.summer2024.presentation.rest;
|
||||
|
||||
import fr.kektus.summer2024.Validators;
|
||||
import fr.kektus.summer2024.domain.entity.LocationEntity;
|
||||
import fr.kektus.summer2024.domain.service.AuthService;
|
||||
import fr.kektus.summer2024.domain.service.LocationService;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.NotAuthorizedException;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.With;
|
||||
import org.jboss.resteasy.reactive.RestHeader;
|
||||
|
||||
@Path("/admin/location")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class AdminLocationApi {
|
||||
@Inject AuthService authService;
|
||||
@Inject LocationService locationService;
|
||||
|
||||
@POST
|
||||
public LocationEntity createPost(@RestHeader("X-admin-token") final String adminToken,
|
||||
CreateLocation.Request request) {
|
||||
if (!authService.isAdminTokenValid(adminToken))
|
||||
throw new NotAuthorizedException("provided admin token is invalid");
|
||||
|
||||
Validators.assertNotNull(request, new BadRequestException());
|
||||
Validators.assertNotNull(request.latitude, new BadRequestException());
|
||||
Validators.assertNotNull(request.longitude, new BadRequestException());
|
||||
|
||||
return locationService.createLocation(request.latitude, request.longitude);
|
||||
}
|
||||
|
||||
public static class CreateLocation {
|
||||
@NoArgsConstructor @AllArgsConstructor @With
|
||||
public static class Request {
|
||||
public Float latitude;
|
||||
public Float longitude;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package fr.kektus.summer2024.presentation.rest;
|
||||
|
||||
import fr.kektus.summer2024.domain.entity.LocationEntity;
|
||||
import fr.kektus.summer2024.domain.service.LocationService;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
|
||||
@Path("/location")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class LocationApi {
|
||||
@Inject LocationService locationService;
|
||||
|
||||
@GET @Path("/last")
|
||||
public LocationEntity getLastLocation() {
|
||||
return locationService.getLatestLocation();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
create table Location
|
||||
(
|
||||
id bigserial not null primary key,
|
||||
latitude float4,
|
||||
longitude float4,
|
||||
timestamp timestamp(6) with time zone
|
||||
);
|
||||
|
|
@ -6,6 +6,7 @@ const CreatePostView = () => import('@/views/CreatePostView.vue')
|
|||
const AdminView = () => import('@/views/AdminView.vue')
|
||||
const LoginView = () => import('@/views/LoginView.vue')
|
||||
const MapView = () => import('@/views/MapView.vue')
|
||||
const SendLocationView = () => import('@/views/SendLocationView.vue')
|
||||
|
||||
function authenticatedRoute(to) {
|
||||
const authStore = useAuthStore()
|
||||
|
|
@ -46,6 +47,12 @@ const router = createRouter({
|
|||
name: 'admin_create_post',
|
||||
component: CreatePostView,
|
||||
beforeEnter: [authenticatedRoute]
|
||||
},
|
||||
{
|
||||
path: '/admin/send-location',
|
||||
name: 'admin_send_location',
|
||||
component: SendLocationView,
|
||||
beforeEnter: [authenticatedRoute]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
|
|||
32
summer2024-frontend/src/stores/location.js
Normal file
32
summer2024-frontend/src/stores/location.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { formatRelative } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { API_BASE_URL } from '@/config.js'
|
||||
import { fromLonLat } from 'ol/proj.js'
|
||||
|
||||
export const useLocationStore = defineStore('location', () => {
|
||||
const locationApiPath = API_BASE_URL + '/location/last'
|
||||
|
||||
const lastLocation = ref(null)
|
||||
|
||||
function fetchLocation() {
|
||||
return fetch(locationApiPath)
|
||||
.then((response) => {
|
||||
return response.json()
|
||||
})
|
||||
.then(async (loc) => {
|
||||
const locDate = new Date(loc.timestamp)
|
||||
loc.formatedDate = formatRelative(locDate, new Date(), { locale: fr })
|
||||
loc.projectedCoordinates = fromLonLat([loc.longitude, loc.latitude])
|
||||
lastLocation.value = loc
|
||||
|
||||
return location.value
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('fetch last location failed with: ' + error)
|
||||
})
|
||||
}
|
||||
|
||||
return { lastLocation, fetchLocation }
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup>
|
||||
|
||||
import { Button } from '@/components/ui/button/index.js'
|
||||
import { CirclePlus, Pencil, Trash } from 'lucide-vue-next'
|
||||
import { CirclePlus, Pencil, Trash, MapPin } from 'lucide-vue-next'
|
||||
import { useAdminPostsStore } from '@/stores/adminPosts.js'
|
||||
import { onMounted } from 'vue'
|
||||
import {
|
||||
|
|
@ -27,6 +27,12 @@ onMounted(() => {
|
|||
<h1 class="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mb-6">
|
||||
Admin
|
||||
</h1>
|
||||
<RouterLink to="/admin/send-location">
|
||||
<Button class="mb-6 mr-2">
|
||||
<MapPin class="mr-2" />
|
||||
Envoyer localisation
|
||||
</Button>
|
||||
</RouterLink>
|
||||
<RouterLink to="/admin/post/create">
|
||||
<Button class="mb-6">
|
||||
<CirclePlus class="mr-2" />
|
||||
|
|
|
|||
|
|
@ -2,13 +2,18 @@
|
|||
|
||||
import { usePostsStore } from '@/stores/posts.js'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { Camera } from 'lucide-vue-next'
|
||||
import { Camera, MapPin } from 'lucide-vue-next'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip/index.js'
|
||||
import { useLocationStore } from '@/stores/location.js'
|
||||
|
||||
const postStore = usePostsStore()
|
||||
const locationStore = useLocationStore()
|
||||
|
||||
const postsLinesCoordinates = computed(() => {
|
||||
const coords = []
|
||||
if (locationStore.lastLocation != null) {
|
||||
coords.push(locationStore.lastLocation.projectedCoordinates)
|
||||
}
|
||||
for (const post of postStore.posts) {
|
||||
coords.push(post.projectedCoordinates)
|
||||
}
|
||||
|
|
@ -17,6 +22,7 @@ const postsLinesCoordinates = computed(() => {
|
|||
|
||||
onMounted(() => {
|
||||
postStore.fetchPosts()
|
||||
locationStore.fetchLocation()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -59,8 +65,27 @@ onMounted(() => {
|
|||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</ol-overlay>
|
||||
<ol-overlay
|
||||
v-if="locationStore.lastLocation != null"
|
||||
:position="locationStore.lastLocation.projectedCoordinates"
|
||||
:autoPan="true"
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip :default-open="true">
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="-translate-x-1/2 -translate-y-1/2 p-1 rounded-lg bg-rose-950 hover:bg-rose-900 transition-colors duration-200">
|
||||
<MapPin size="16" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Dernière position, {{ locationStore.lastLocation.formatedDate }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</ol-overlay>
|
||||
|
||||
<ol-vector-layer v-if="postStore.posts.length > 1">
|
||||
<ol-vector-layer v-if="postsLinesCoordinates.length > 1">
|
||||
<ol-source-vector>
|
||||
<ol-feature ref="profileFeatureRef">
|
||||
<ol-geom-line-string
|
||||
|
|
|
|||
137
summer2024-frontend/src/views/SendLocationView.vue
Normal file
137
summer2024-frontend/src/views/SendLocationView.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<script setup>
|
||||
import { Button } from '@/components/ui/button/index.js'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form/index.js'
|
||||
import { Input } from '@/components/ui/input/index.js'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { z } from 'zod'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { AlertCircle, ArrowLeft, CircleCheckBig, Send } from 'lucide-vue-next'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert/index.js'
|
||||
import { useAuthStore } from '@/stores/auth.js'
|
||||
import { API_BASE_URL } from '@/config.js'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
latitude: z.number(),
|
||||
longitude: z.number()
|
||||
}))
|
||||
const form = useForm({
|
||||
validationSchema: formSchema
|
||||
})
|
||||
|
||||
const formContainer = ref(null)
|
||||
const latitudeInput = ref(null)
|
||||
const longitudeInput = ref(null)
|
||||
|
||||
const formStatus = ref({
|
||||
sending: false,
|
||||
sent: false,
|
||||
error: false,
|
||||
errorMsg: ''
|
||||
})
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
if (!authStore.isAuth)
|
||||
return
|
||||
|
||||
console.log('Envoi de la localisation...')
|
||||
formContainer.value.classList.add('hidden')
|
||||
formStatus.value.sending = true
|
||||
|
||||
const response = await fetch(API_BASE_URL + '/admin/location', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-admin-token': authStore.adminToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
latitude: values.latitude,
|
||||
longitude: values.longitude
|
||||
})
|
||||
})
|
||||
if (!response.ok) {
|
||||
console.log('POST post API failed: ' + response.statusText + '\n\n' + response.body)
|
||||
formStatus.value.sending = false
|
||||
formStatus.value.error = true
|
||||
formStatus.value.errorMsg = 'Une erreur est survenue lors de l\'envoi du poste : ' + response.statusText
|
||||
formContainer.value.classList.remove('hidden')
|
||||
return
|
||||
}
|
||||
|
||||
formStatus.value.sending = false
|
||||
formStatus.value.sent = true
|
||||
})
|
||||
|
||||
let geoWatchId = null
|
||||
|
||||
onMounted(() => {
|
||||
geoWatchId = navigator.geolocation.watchPosition((position) => {
|
||||
console.log(position)
|
||||
form.setFieldValue('latitude', position.coords.latitude)
|
||||
form.setFieldValue('longitude', position.coords.longitude)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (geoWatchId != null) {
|
||||
navigator.geolocation.clearWatch(geoWatchId)
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-3 grid-rows-1 mt-28 sm:mt-20 w-full justify-center items-center gap-4">
|
||||
<div class="col-span-3 col-start-1 lg:col-span-1 lg:col-start-2 mx-5 lg:mx-0">
|
||||
<h1 class="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mb-6">
|
||||
Envoyer la localisation
|
||||
</h1>
|
||||
<RouterLink to="/admin">
|
||||
<Button class="mb-6">
|
||||
<ArrowLeft class="mr-2" />
|
||||
Retour
|
||||
</Button>
|
||||
</RouterLink>
|
||||
<Alert variant="destructive" class="mb-6" v-if="formStatus.error">
|
||||
<AlertCircle class="w-4 h-4"></AlertCircle>
|
||||
<AlertTitle>Erreur...</AlertTitle>
|
||||
<AlertDescription>{{ formStatus.errorMsg }}</AlertDescription>
|
||||
</Alert>
|
||||
<Alert class="mb-6" v-if="formStatus.sent">
|
||||
<CircleCheckBig class="w-4 h-4"></CircleCheckBig>
|
||||
<AlertTitle>Localisation envoyée !</AlertTitle>
|
||||
</Alert>
|
||||
<p v-if="formStatus.sending">Sending...</p>
|
||||
<div id="form-container" ref="formContainer">
|
||||
<form id="post-form" class="space-y-6" @submit="onSubmit">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormField ref="latitudeInput" v-slot="{ componentField }" name="latitude">
|
||||
<FormItem>
|
||||
<FormLabel>Latitude</FormLabel>
|
||||
<FormControl>
|
||||
<Input disabled v-bind="componentField"></Input>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField ref="longitudeInput" v-slot="{ componentField }" name="longitude">
|
||||
<FormItem>
|
||||
<FormLabel>Longitude</FormLabel>
|
||||
<FormControl>
|
||||
<Input disabled v-bind="componentField"></Input>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
<Button class="mt-6 w-full" type="submit">
|
||||
<Send />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue