feat: integrate map for report creation

This commit is contained in:
2026-06-13 10:22:15 +02:00 Unverified
parent ca866404af
commit 3530eea2b8
3 changed files with 889 additions and 1654 deletions
+115 -28
View File
@@ -1,5 +1,20 @@
<script setup lang="ts">
import type { Category, CreateReportRequest } from '~/types/api'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
// Fix per le icone di Leaflet in Nuxt/Vite
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon-2x.png',
shadowUrl: 'https://unpkg.com/[email protected]/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
L.Marker.prototype.options.icon = DefaultIcon;
interface Props {
isLoading?: boolean
@@ -30,6 +45,66 @@ const errors = ref<Record<string, string>>({})
const successMessage = ref(false)
const isGettingLocation = ref(false)
// Map variables
const mapContainer = ref<HTMLElement | null>(null)
let map: L.Map | null = null
let marker: L.Marker | null = null
const initMap = () => {
if (!mapContainer.value || map) return
// Centro Italia default
const defaultLat = 41.8719
const defaultLng = 12.5674
const zoom = 5
map = L.map(mapContainer.value).setView([defaultLat, defaultLng], zoom)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map)
map.on('click', (e: L.LeafletMouseEvent) => {
updateLocation(e.latlng.lat, e.latlng.lng)
})
// Se c'è già una posizione, imposta il marker
if (form.value.latitude && form.value.longitude) {
updateLocation(form.value.latitude, form.value.longitude, true)
}
}
const updateLocation = (lat: number, lng: number, zoomTo = false) => {
form.value.latitude = Number(lat.toFixed(6))
form.value.longitude = Number(lng.toFixed(6))
if (map) {
if (!marker) {
marker = L.marker([lat, lng]).addTo(map)
} else {
marker.setLatLng([lat, lng])
}
if (zoomTo) {
map.setView([lat, lng], 16)
}
}
if (errors.value.location) {
errors.value.location = ''
}
}
onMounted(() => {
// Map logic is handled by ClientOnly placeholder if needed, but we initialize here
})
onUnmounted(() => {
if (map) {
map.remove()
map = null
}
})
const validateForm = (): boolean => {
errors.value = {}
@@ -37,7 +112,7 @@ const validateForm = (): boolean => {
errors.value.category_id = 'Seleziona una categoria'
}
if (!form.value.latitude || !form.value.longitude) {
errors.value.location = 'Imposta la posizione'
errors.value.location = 'Imposta la posizione sulla mappa'
}
if (!form.value.address?.trim()) {
errors.value.address = 'Inserisci l\'indirizzo'
@@ -63,13 +138,9 @@ const getGeolocation = async () => {
navigator.geolocation.getCurrentPosition(resolve, reject)
})
form.value.latitude = Number(position.coords.latitude.toFixed(6))
form.value.longitude = Number(position.coords.longitude.toFixed(6))
// Richiedi indirizzo da coordinate (reverse geocoding)
// Se il backend non lo fa, userà le coord così come sono
updateLocation(position.coords.latitude, position.coords.longitude, true)
} catch (error: any) {
errors.value.location = 'Impossibile ottenere la posizione. Inserisci manualmente.'
errors.value.location = 'Impossibile ottenere la posizione. Clicca sulla mappa per inserirla manualmente.'
} finally {
isGettingLocation.value = false
}
@@ -99,26 +170,43 @@ const resetForm = () => {
}
media.value = []
errors.value = {}
if (marker && map) {
map.removeLayer(marker)
marker = null
map.setView([41.8719, 12.5674], 5)
}
}
// Osserva cambio categoria
watch(() => form.value.category_id, () => {
if (errors.value.category_id) {
errors.value.category_id = ''
}
})
// Sincronizza input manuali con la mappa
watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]) => {
if (newLat && newLng && map) {
if (!marker || marker.getLatLng().lat !== newLat || marker.getLatLng().lng !== newLng) {
if (!marker) {
marker = L.marker([newLat, newLng]).addTo(map)
} else {
marker.setLatLng([newLat, newLng])
}
map.setView([newLat, newLng], 16)
}
}
})
</script>
<template>
<div class="report-form">
<!-- Successo -->
<div v-if="successMessage" class="alert alert-success alert-dismissible fade show" role="alert">
Segnalazione creata con successo! Reindirizzamento...
<button type="button" class="btn-close" @click="successMessage = false"></button>
</div>
<form @submit.prevent="handleSubmit">
<!-- CATEGORIA -->
<div class="mb-4">
<label for="category" class="form-label fw-bold">
<BIIcon name="it-list" size="sm" color="primary" class="me-2" />
@@ -141,20 +229,12 @@ watch(() => form.value.category_id, () => {
</div>
</div>
<!-- GEOLOCALIZZAZIONE -->
<div class="mb-4">
<label class="form-label fw-bold">
<BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
Posizione *
</label>
<div v-if="form.latitude && form.longitude" class="alert alert-info mb-3">
<strong>Coordinate:</strong> {{ form.latitude }}, {{ form.longitude }}
<button type="button" class="btn btn-sm btn-link float-end" @click="form.latitude = null; form.longitude = null">
Cancella
</button>
</div>
<button
type="button"
class="btn btn-outline-primary w-100 mb-3"
@@ -166,9 +246,14 @@ watch(() => form.value.category_id, () => {
{{ isGettingLocation ? 'Localizzazione in corso...' : 'Usa posizione attuale' }}
</button>
<div class="alert alert-warning mb-3 small" role="alert">
Se la geolocalizzazione non funziona, inserisci manualmente coordinate o indirizzo approssimativo.
</div>
<ClientOnly>
<div
ref="mapContainer"
class="map-container mb-3 border rounded"
:class="{ 'border-danger': errors.location }"
@vue:mounted="initMap"
></div>
</ClientOnly>
<div class="row">
<div class="col-md-6 mb-3">
@@ -176,7 +261,7 @@ watch(() => form.value.category_id, () => {
v-model.number="form.latitude"
type="number"
step="0.000001"
class="form-control"
class="form-control form-control-sm"
:class="{ 'is-invalid': errors.location }"
placeholder="Latitudine (es. 45.5)"
required
@@ -187,19 +272,18 @@ watch(() => form.value.category_id, () => {
v-model.number="form.longitude"
type="number"
step="0.000001"
class="form-control"
class="form-control form-control-sm"
:class="{ 'is-invalid': errors.location }"
placeholder="Longitudine (es. 10.2)"
required
/>
</div>
</div>
<div v-if="errors.location" class="invalid-feedback d-block">
<div v-if="errors.location" class="invalid-feedback d-block mt-0 mb-3">
{{ errors.location }}
</div>
</div>
<!-- INDIRIZZO -->
<div class="mb-4">
<label for="address" class="form-label fw-bold">
<BIIcon name="it-home" size="sm" color="primary" class="me-2" />
@@ -219,7 +303,6 @@ watch(() => form.value.category_id, () => {
</div>
</div>
<!-- DESCRIZIONE -->
<div class="mb-4">
<label for="description" class="form-label fw-bold">
<BIIcon name="it-write" size="sm" color="primary" class="me-2" />
@@ -242,7 +325,6 @@ watch(() => form.value.category_id, () => {
</div>
</div>
<!-- MEDIA -->
<div class="mb-4">
<label class="form-label fw-bold">
<BIIcon name="it-camera" size="sm" color="primary" class="me-2" />
@@ -251,7 +333,6 @@ watch(() => form.value.category_id, () => {
<MediaUploader v-model="media" :max-files="5" :max-file-size="50" />
</div>
<!-- BOTTONI -->
<div class="d-grid gap-2 d-md-flex justify-content-md-between">
<button
type="button"
@@ -281,6 +362,12 @@ watch(() => form.value.category_id, () => {
padding: 2rem;
}
.map-container {
height: 300px;
width: 100%;
z-index: 1; /* Fix for Bootstrap elements overlapping map */
}
textarea {
font-family: inherit;
resize: vertical;