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 AdminView = () => import('@/views/AdminView.vue')
|
||||||
const LoginView = () => import('@/views/LoginView.vue')
|
const LoginView = () => import('@/views/LoginView.vue')
|
||||||
const MapView = () => import('@/views/MapView.vue')
|
const MapView = () => import('@/views/MapView.vue')
|
||||||
|
const SendLocationView = () => import('@/views/SendLocationView.vue')
|
||||||
|
|
||||||
function authenticatedRoute(to) {
|
function authenticatedRoute(to) {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
@ -46,6 +47,12 @@ const router = createRouter({
|
||||||
name: 'admin_create_post',
|
name: 'admin_create_post',
|
||||||
component: CreatePostView,
|
component: CreatePostView,
|
||||||
beforeEnter: [authenticatedRoute]
|
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>
|
<script setup>
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button/index.js'
|
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 { useAdminPostsStore } from '@/stores/adminPosts.js'
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import {
|
import {
|
||||||
|
|
@ -27,6 +27,12 @@ onMounted(() => {
|
||||||
<h1 class="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mb-6">
|
<h1 class="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mb-6">
|
||||||
Admin
|
Admin
|
||||||
</h1>
|
</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">
|
<RouterLink to="/admin/post/create">
|
||||||
<Button class="mb-6">
|
<Button class="mb-6">
|
||||||
<CirclePlus class="mr-2" />
|
<CirclePlus class="mr-2" />
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,18 @@
|
||||||
|
|
||||||
import { usePostsStore } from '@/stores/posts.js'
|
import { usePostsStore } from '@/stores/posts.js'
|
||||||
import { computed, onMounted } from 'vue'
|
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip/index.js'
|
||||||
|
import { useLocationStore } from '@/stores/location.js'
|
||||||
|
|
||||||
const postStore = usePostsStore()
|
const postStore = usePostsStore()
|
||||||
|
const locationStore = useLocationStore()
|
||||||
|
|
||||||
const postsLinesCoordinates = computed(() => {
|
const postsLinesCoordinates = computed(() => {
|
||||||
const coords = []
|
const coords = []
|
||||||
|
if (locationStore.lastLocation != null) {
|
||||||
|
coords.push(locationStore.lastLocation.projectedCoordinates)
|
||||||
|
}
|
||||||
for (const post of postStore.posts) {
|
for (const post of postStore.posts) {
|
||||||
coords.push(post.projectedCoordinates)
|
coords.push(post.projectedCoordinates)
|
||||||
}
|
}
|
||||||
|
|
@ -17,6 +22,7 @@ const postsLinesCoordinates = computed(() => {
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
postStore.fetchPosts()
|
postStore.fetchPosts()
|
||||||
|
locationStore.fetchLocation()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -59,8 +65,27 @@ onMounted(() => {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ol-overlay>
|
</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-source-vector>
|
||||||
<ol-feature ref="profileFeatureRef">
|
<ol-feature ref="profileFeatureRef">
|
||||||
<ol-geom-line-string
|
<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