13 Commits
17 changed files with 3905 additions and 506 deletions
+103 -53
View File
@@ -1,75 +1,125 @@
# Nuxt Minimal Starter
<div align="center">
<img src="https://git.reloia.uk/SnitchItaly/frontend/raw/branch/master/public/icon-512.webp" width="128" height="128" alt="Logo Visione">
<h1>Visione</h1>
<p><b>Un'occhio per la sicurezza del cittadino.</b></p>
</div>
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
**Visione** è una piattaforma progettata per gestire le segnalazioni dei cittadini, implementando un flusso operativo
sicuro, trasparente e istituzionalmente conforme.
## Setup
## Punti di Forza e Funzionalità Principali
Make sure to install dependencies:
- **Conformità Istituzionale e UX/UI Standardizzata:** Il frontend è sviluppato utilizzando **Bootstrap Italia** (creato
da Designers Italia). Questa scelta assicura un'identità visiva coerente con i servizi pubblici italiani, garantendo
uniformità, riconoscibilità e accessibilità.
- **Trasparenza e Open Data:** Il sistema espone un endpoint pubblico Open Data con dataset anonimizzati relativi alle
segnalazioni chiuse o risolte, favorendo la trasparenza amministrativa.
- **Automazione e Smistamento Intelligente:** Il calcolo automatico della "Zona" di competenza tramite le coordinate
geografiche elimina i colli di bottiglia durante il triage iniziale, instradando immediatamente la segnalazione
all'operatore corretto.
- **Sicurezza e Onboarding Smart:** L'autenticazione è gestita tramite **OTP (One-Time Password)** e integrazione con *
*CIE (Carta d'Identità Elettronica)**, riducendo l'uso di password statiche e garantendo identità verificate.
- **Infrastruttura DevOps e CI/CD:** Sfruttiamo un'istanza self-hosted di **Gitea** per il versionamento del codice e un
flusso di deployment automatizzato basato su webhook con **Dokploy**, assicurando un rilascio continuo e stabile.
- **Ottimizzazione SEO e Reperibilità:** Configurazione avanzata per sitemap e `robots.txt` (via `@nuxtjs/robots` e
`@nuxtjs/sitemap`) per esplicitare chiaramente le pagine pubbliche e separarle dalle aree private.
## Tecnologie Utilizzate
Il progetto è costruito su uno stack moderno orientato a performance, accessibilità e manutenibilità:
- **Framework Core:** [Nuxt 4](https://nuxt.com/) (Vue 3) con rendering SSR attivato.
- **UI / Styling:** [Bootstrap Italia](https://italia.github.io/bootstrap-italia/) per componenti accessibili e conformi
agli standard AGID.
- **Mappe e Georeferenziazione:** [Leaflet](https://leafletjs.com/) interfacciato con Nominatim/OpenStreetMap per il
suggerimento e la validazione degli indirizzi.
- **PWA e Ottimizzazione:** Modulo `@vite-pwa/nuxt` per Progressive Web App (manifest auto-generato) e moduli ufficiali
Nuxt per icone, font (Google Fonts: Lora, Roboto Mono, Titillium Web) ed elaborazione immagini.
- **Infrastruttura:** Docker, Docker Compose, NodeJS 22.
- **Package Manager:** **Yarn**.
## Limiti e Punti di Attenzione
L'attuale architettura presenta alcune criticità e aree di miglioramento individuate durante i test:
1. **Rischio di Abuso e Moderazione:** L'apertura della piattaforma all'invio di messaggi e allegati potrebbe generare
un elevato carico di moderazione. In futuro, andranno valutati filtri automatici o code dedicate.
2. **Dipendenza da API Cartografiche di Terze Parti:** L'utilizzo di Nominatim/OpenStreetMap per il geocoding esonera
dal lock-in di provider commerciali (es. Google Maps), ma introduce dipendenze su disponibilità e policy d'uso di
servizi esterni.
3. **Barriere all'Ingresso:** I flussi OTP e CIE, seppur estremamente sicuri, possono rappresentare un "attrito" (
digital divide) per le fasce di utenza più anziane o meno avvezze agli strumenti digitali.
---
## Come utilizzare il progetto (Sviluppo)
### Requisiti
- Node.js (v22 o superiore consigliato)
- **Yarn** (utilizza esclusivamente `yarn`, non `npm`)
### 1. Installazione Dipendenze
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
### 2. Configurazione Variabili d'Ambiente (.env)
Start the development server on `http://localhost:3000`:
Copia il file di esempio per creare la tua configurazione locale:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
cp .env.example .env
```
## Production
Il file `.env` richiede le seguenti chiavi:
Build the application for production:
- `NUXT_PUBLIC_URL`: URL pubblico dell'applicativo frontend (es. `http://localhost:3000`).
- `NUXT_PUBLIC_API`: URL base per il backend API (es. `http://localhost:5555`).
*(Nota: il proxy integrato in `nuxt.config.ts` inoltrerà già le richieste di `/api/v1` al target di staging
configurato `https://api.visionebari.it` in ambiente di sviluppo).*
### 3. Avvio del Server di Sviluppo
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
yarn run dev
```
Locally preview production build:
Il server sarà accessibile all'indirizzo [http://localhost:3000](http://localhost:3000).
---
## Distribuzione e Produzione (Docker)
Il progetto è ottimizzato per essere deployato tramite container Docker, sfruttando processi multi-stage per generare
un'immagine finale snella e performante basata su `node:22-bookworm`.
### Build locale e avvio tramite Docker Compose
Il file `docker-compose.yml` espone il servizio `visione-frontend`.
1. Assicurati che il file `.env` sia valorizzato correttamente.
2. Esegui il build e avvia in background:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
docker compose up -d --build
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
### Dettagli sul Dockerfile
- **Fase di Build (`AS build`):** Copia `package.json` e `yarn.lock`, esegue `yarn install`, copia i sorgenti ed esegue
`yarn run build`. Iniettando gli argomenti di environment per le API.
- **Fase di Run (`AS run`):** Esegue `yarn install --production` per alleggerire le dipendenze finali. Trasferisce
l'artefatto compilato `.output` e avvia il webserver sulla porta `80` tramite `node /app/server/index.mjs`.
## Testing e Azioni Migliorative Future
I test attuali coprono con successo i flussi primari (registrazione, autenticazione, gestione segnalazioni,
moderazione).
Prossimi step suggeriti:
- Consolidamento con test end-to-end (E2E) automatici per le rotte critiche (in particolare CIE/OTP).
- Verifiche di regressione per gli endpoint Open Data per garantirne l'integrità.
- Monitoraggio della qualità del dato geografico (segnalazioni in aree limite).
+1
View File
@@ -9,6 +9,7 @@ useSeoMeta({
});
</script>
<template>
<NuxtPwaManifest/>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
+8 -1
View File
@@ -74,7 +74,7 @@ const navLinks = [
<BIIcon name="it-user" color="black"/>
<span v-if="hasUnreadNotifications" class="notification-dot"></span>
</span>
<span class="d-none d-lg-block">{{ user?.first_name }}</span>
<span class="d-none d-lg-block username">{{ user?.first_name }}</span>
</NuxtLink>
<button class="btn btn-primary btn-icon btn-full border-0" @click="logout"
@@ -310,4 +310,11 @@ const navLinks = [
background: #d9364f;
border: 2px solid #ffffff;
}
.username {
max-width: 8ch;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<script lang="ts" setup>
interface Props {
modelValue?: File[]
maxFiles?: number
acceptedTypes?: string
maxFileSize?: number // in MB
disabled?: boolean
}
interface Emits {
(e: 'update:modelValue', files: File[]): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
maxFiles: 5,
acceptedTypes: 'image/*,video/*',
maxFileSize: 50,
disabled: false
})
const emit = defineEmits<Emits>()
const fileInputRef = ref<HTMLInputElement>()
const isDragging = ref(false)
const error = ref('')
const files = computed({
get: () => props.modelValue || [],
set: (value) => emit('update:modelValue', value)
})
const handleFilesSelected = (selectedFiles: FileList | null) => {
if (!selectedFiles) return
error.value = ''
const newFiles = Array.from(selectedFiles)
if (files.value.length + newFiles.length > props.maxFiles) {
error.value = `Massimo ${props.maxFiles} file consentiti`
return
}
for (const file of newFiles) {
if (file.size > props.maxFileSize! * 1024 * 1024) {
error.value = `Il file ${file.name} supera il limite di ${props.maxFileSize}MB`
return
}
}
files.value = [...files.value, ...newFiles]
}
const removeFile = (index: number) => {
files.value.splice(index, 1)
}
const openFileDialog = () => {
fileInputRef.value?.click()
}
const formatFileSize = (bytes: number): string => {
const mb = Math.round(bytes / (1024 * 1024) * 10) / 10
return mb > 0 ? `${mb}MB` : 'Piccolo'
}
const getFileUrl = (file: File) => {
return URL.createObjectURL(file)
}
</script>
<template>
<div class="media-uploader">
<div
:class="{ 'border-primary bg-primary bg-opacity-10': isDragging, 'bg-light': !isDragging }"
class="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer position-relative"
@click="!disabled && openFileDialog()"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="isDragging = false; handleFilesSelected(($event as DragEvent).dataTransfer?.files || null)"
>
<BIIcon class="mb-3" color="primary" name="it-upload" size="xl"/>
<div class="h6 font-weight-bold">
Trascina i file qui o <span class="text-primary text-decoration-underline">clicca per selezionare</span>
</div>
<small class="text-muted d-block mt-2">
Massimo {{ maxFiles }} file ({{ maxFileSize }}MB max per file). Tipi: {{ acceptedTypes }}
</small>
</div>
<input
ref="fileInputRef"
:accept="acceptedTypes"
:disabled="disabled"
:multiple="maxFiles > 1"
class="d-none"
type="file"
@change="handleFilesSelected(($event.target as HTMLInputElement).files)"
/>
<div v-if="error" class="alert alert-danger mt-3 py-2 small" role="alert">
{{ error }}
</div>
<div v-if="files.length > 0" class="mt-4 row g-3">
<div v-for="(file, idx) in files" :key="`file-${idx}`" class="col-6 col-md-4 col-lg-3">
<div class="card h-100 position-relative shadow-sm border-0">
<div class="ratio ratio-1x1 bg-light rounded-top overflow-hidden">
<img v-if="file.type.startsWith('image/')" :alt="file.name" :src="getFileUrl(file)"
class="object-fit-cover"/>
<video v-else-if="file.type.startsWith('video/')" :src="getFileUrl(file)" class="object-fit-cover"></video>
<div v-else class="d-flex align-items-center justify-content-center bg-secondary bg-opacity-25">
<BIIcon color="secondary" name="it-file" size="xl"/>
</div>
</div>
<button
:disabled="disabled"
class="btn btn-danger btn-sm position-absolute top-0 end-0 m-1 rounded-circle p-1 d-flex align-items-center justify-content-center shadow"
style="width: 28px; height: 28px; z-index: 10;"
title="Rimuovi file"
type="button"
@click.stop="removeFile(idx)"
>
<BIIcon color="white" name="it-close" size="sm"/>
</button>
<div class="card-body p-2 d-flex flex-column justify-content-between">
<div :title="file.name" class="small text-truncate fw-semibold">{{ file.name }}</div>
<div class="small text-muted d-flex justify-content-between align-items-center mt-1">
<span>{{ formatFileSize(file.size) }}</span>
<div class="progress flex-grow-1 ms-2" style="height: 4px;" title="Pronto per l'upload">
<div class="progress-bar bg-success w-100" role="progressbar"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.upload-area {
transition: all 0.2s ease-in-out;
}
.upload-area:hover:not(.border-primary) {
background-color: #f8f9fa !important;
border-color: #0066cc !important;
}
.cursor-pointer {
cursor: pointer;
}
.border-dashed {
border-style: dashed !important;
}
.border-2 {
border-width: 2px !important;
}
.object-fit-cover {
object-fit: cover;
width: 100%;
height: 100%;
}
</style>
+2 -2
View File
@@ -70,8 +70,8 @@ export const useAuth = () => {
const res = await fetchCurrentUser()
if (res.ok) {
user.value = res.user
await refreshUnreadNotifications()
const path = currentPath ?? useNuxtApp().$router.currentRoute.value.path
await nuxtApp.runWithContext(() => refreshUnreadNotifications())
const path = currentPath ?? nuxtApp.$router.currentRoute.value.path
if (redirectOnSuccess && path.startsWith('/auth')) {
await nuxtApp.runWithContext(() => navigateTo('/dashboard'))
+13 -3
View File
@@ -1,11 +1,17 @@
import type {
CategoryListResponse,
CreateReportRequest,
CreateReportResponse, FeedbackRequest, FlagRequest, FlagResponse,
CreateReportResponse,
FeedbackRequest,
FlagRequest,
FlagResponse,
MediaUploadResponse,
MessageListResponse, MessageRequest, OkResponse,
MessageListResponse,
MessageRequest,
OkResponse,
ReportDetailResponse,
ReportListResponse
ReportListResponse,
TeamListResponse
} from "~/types/api";
export const useReports = () => {
@@ -39,6 +45,9 @@ export const useReports = () => {
const flagMessage = (reportId: number, messageId: number, body: FlagRequest) =>
useApiFetch<FlagResponse>(`/api/v1/reports/${reportId}/messages/${messageId}/flags`, { method: 'POST', body })
const getTeamSuggestions = (id: number) =>
useApiFetch<TeamListResponse>(`/api/v1/reports/${id}/team-suggestions`, { method: 'GET' })
return {
getReports,
getCategories,
@@ -50,5 +59,6 @@ export const useReports = () => {
addFeedback,
flagReport,
flagMessage,
getTeamSuggestions,
}
}
+35 -8
View File
@@ -70,11 +70,27 @@ const reportsByStatus = computed(() =>
.filter((item) => item.total > 0)
)
const recentReports = computed(() =>
[...filteredReports.value]
.sort((a, b) => new Date(b.created_at || '').getTime() - new Date(a.created_at || '').getTime())
.slice(0, 12)
const currentPage = ref(1)
const itemsPerPage = ref(12)
watch([search, selectedStatus, selectedCategoryId, selectedAreaId], () => {
currentPage.value = 1
})
const totalPages = computed(() => Math.ceil(filteredReports.value.length / itemsPerPage.value))
const paginatedReports = computed(() => {
const sorted = [...filteredReports.value].sort(
(a, b) => new Date(b.created_at || '').getTime() - new Date(a.created_at || '').getTime()
)
const start = (currentPage.value - 1) * itemsPerPage.value
const end = start + itemsPerPage.value
return sorted.slice(start, end)
})
const handlePageChange = (page: number) => {
currentPage.value = page
}
const formatDate = (value?: string) => {
if (!value) return '-'
@@ -160,7 +176,7 @@ const truncate = (value?: string, max = 120) => {
<div class="card-body">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<h2 class="h5 mb-0">Distribuzione stati</h2>
<span class="text-muted small">{{ filteredReports.length }} elementi visibili</span>
<span class="text-muted small">{{ filteredReports.length }} elementi trovati</span>
</div>
<div class="table-responsive report-table-wrapper">
<table class="table table-hover report-table mb-0 align-middle">
@@ -185,11 +201,11 @@ const truncate = (value?: string, max = 120) => {
</div>
</div>
<div v-if="recentReports.length === 0" class="alert alert-info">
<div v-if="filteredReports.length === 0" class="alert alert-info">
Nessuna segnalazione trovata con filtri correnti.
</div>
<div v-else class="card card-bg border-0 shadow-sm">
<div v-else class="card card-bg border-0 shadow-sm mb-5">
<div class="card-body p-0">
<div class="table-responsive report-table-wrapper">
<table class="table table-hover report-table mb-0 align-middle">
@@ -205,7 +221,7 @@ const truncate = (value?: string, max = 120) => {
</tr>
</thead>
<tbody>
<tr v-for="report in recentReports" :key="report.id">
<tr v-for="report in paginatedReports" :key="report.id">
<td class="report-table-id">#{{ report.id }}</td>
<td>
<div class="fw-semibold">{{ report.category_name || '-' }}</div>
@@ -225,6 +241,17 @@ const truncate = (value?: string, max = 120) => {
</table>
</div>
</div>
<div class="card-footer bg-transparent d-flex flex-wrap justify-content-between align-items-center py-3" v-if="totalPages > 1">
<span class="text-muted small mb-2 mb-sm-0">Pagina {{ currentPage }} di {{ totalPages }}</span>
<Pagination
:current-page="currentPage"
:per-page="itemsPerPage"
:total-items="filteredReports.length"
@page-change="handlePageChange"
/>
</div>
</div>
</template>
</div>
+4 -5
View File
@@ -36,7 +36,7 @@ const getStatusBadge = (status: string) => {
<div class="it-hero-text-wrapper p-4">
<h1 class="no_toc font-weight-bold">Miglioriamo insieme la città</h1>
<p class="d-none d-lg-block mt-3 mb-4 text-muted">
Il portale ufficiale nazionale per segnalare disservizi urbani. Scatta una foto, invia la segnalazione
Il portale ufficiale della città metropolitana di Bari per segnalare disservizi urbani. Scatta una foto, invia la segnalazione
e monitora l'intervento in totale trasparenza.
</p>
<div class="it-btn-container d-flex justify-content-center gap-3">
@@ -61,22 +61,21 @@ const getStatusBadge = (status: string) => {
<BIIcon color="primary" name="it-camera" size="xl"/>
</div>
<h3 class="h5 font-weight-semibold">1. Segnala</h3>
<p class="text-muted">Usa lo smartphone per fotografare il problema stradale e inviare la posizione
esatta.</p>
<p class="text-muted">Fotografa il problema stradale e condividi la tua posizione.</p>
</div>
<div class="col-12 col-md-4 mb-4">
<div class="mb-3">
<BIIcon color="primary" name="it-tool" size="xl"/>
</div>
<h3 class="h5 font-weight-semibold">2. Intervento</h3>
<p class="text-muted">Le squadre comunali prendono in carico la tua segnalazione e avviano i lavori.</p>
<p class="text-muted">Il Comune attiva subito le squadre operative per risolvere il problema.</p>
</div>
<div class="col-12 col-md-4 mb-4">
<div class="mb-3">
<BIIcon color="primary" name="it-star-outline" size="xl"/>
</div>
<h3 class="h5 font-weight-semibold">3. Punti e Feedback</h3>
<p class="text-muted">Problema risolto! Ottieni Punti Cittadino e lascia un feedback sull'intervento.</p>
<p class="text-muted">Problema risolto! Guadagna Punti Cittadino e valuta l'intervento.</p>
</div>
</div>
</div>
+102 -64
View File
@@ -1,7 +1,11 @@
<script setup lang="ts">
import { useMembers } from "~/composables/useMembers";
useHead({
title: 'Informazioni sul Progetto - Visione',
})
const { sortedMembers } = useMembers();
</script>
<template>
@@ -11,10 +15,10 @@ useHead({
<div class="row">
<div class="col-12 text-center">
<div class="it-hero-text-wrapper p-4">
<h1 class="text-white font-weight-bold">Il Progetto Visione</h1>
<h1 class="text-white font-weight-bold">Il Progetto "Visione"</h1>
<p class="text-white mt-3 lead font-weight-semibold">
La piattaforma digitale per la gestione intelligente delle segnalazioni urbane,
pensata per avvicinare cittadini e pubblica amministrazione.
La piattaforma digitale della Città Metropolitana di Bari per la partecipazione attiva,
la trasparenza e la gestione condivisa del territorio.
</p>
</div>
</div>
@@ -25,16 +29,20 @@ useHead({
<div class="container my-5 py-3">
<div class="row">
<div class="col-12 col-lg-8 offset-lg-2">
<h2 class="mb-4 font-weight-bold">Migliorare la qualità della vita in città</h2>
<h2 class="mb-4 font-weight-bold">Un nuovo dialogo tra Cittadini ed Ente</h2>
<p class="lead text-muted">
Visione nasce dall'esigenza di fornire alle amministrazioni comunali uno strumento moderno per mappare,
gestire e risolvere disservizi presenti sul territorio (buche stradali, illuminazione guasta, rifiuti
abbandonati).
Il progetto nasce dall'esigenza di digitalizzare e ottimizzare i flussi di comunicazione tra la cittadinanza
e la Pubblica Amministrazione. Sostituisce i tradizionali canali non tracciabili con un unico punto di
accesso telematico.
</p>
<p>
Il sistema non si limita a raccogliere dati, ma automatizza l'assegnazione alle squadre operative competenti
tramite logiche geospaziali, traccia l'intero ciclo di vita della segnalazione e restituisce trasparenza ai
cittadini tramite un portale Open Data dedicato.
L'adozione della piattaforma persegue un triplice obiettivo strategico: ridurre drasticamente i tempi di
risposta
dell'Ente nella risoluzione dei disservizi (quali buche stradali, illuminazione guasta o degrado urbano),
responsabilizzare la <strong>cittadinanza attiva</strong> tramite meccanismi di partecipazione diretta e
fornire
agli organi competenti un quadro analitico costantemente aggiornato e geolocalizzato delle criticità del
territorio.
</p>
</div>
</div>
@@ -42,7 +50,7 @@ useHead({
<div class="bg-light py-5">
<div class="container">
<h2 class="text-center mb-5 font-weight-bold">Come funziona il sistema</h2>
<h2 class="text-center mb-5 font-weight-bold">Come si articola l'ecosistema digitale</h2>
<div class="row">
<div class="col-12 col-md-4 mb-4">
@@ -50,14 +58,17 @@ useHead({
<div class="card card-bg h-100">
<div class="card-body">
<BIIcon class="mb-3" color="primary" name="it-user" size="xl"/>
<h3 class="card-title h5 font-weight-bold">Cittadini</h3>
<h3 class="card-title h5 font-weight-bold">I Cittadini</h3>
<p class="card-text text-muted">
Tramite autenticazione sicura (OTP via email o CIE), i cittadini possono inviare segnalazioni
geolocalizzate complete di foto.
L'accesso è garantito attraverso l'autocompletamento anagrafico e il supporto ai sistemi di identità
digitale
nazionali (<strong>CIE</strong>).
<br><br>
Per incentivare la partecipazione, è integrato un sistema di <strong>gamification</strong>: ogni
segnalazione valida e risolta fa guadagnare Punti Cittadino, premiando la cittadinanza attiva.
Possono inoltre chattare con gli operatori e lasciare un feedback finale.
Il cittadino può compilare form guidati inserendo descrizioni, documentazione fotografica e
posizione esatta (GPS).
Per incentivare la partecipazione, è previsto il conferimento di "<strong>Punti di Cittadinanza
Attiva</strong>"
per ogni segnalazione validata e risolta dall'Ente.
</p>
</div>
</div>
@@ -69,13 +80,15 @@ useHead({
<div class="card card-bg h-100">
<div class="card-body">
<BIIcon class="mb-3" color="primary" name="it-tool" size="xl"/>
<h3 class="card-title h5 font-weight-bold">Operatori e Squadre</h3>
<h3 class="card-title h5 font-weight-bold">Squadre Operative</h3>
<p class="card-text text-muted">
Il personale comunale visualizza una dashboard con le segnalazioni in ingresso. Possono prenderle in
carico, comunicare con l'utente e assegnarle alle squadre di intervento sul territorio.
Gli operatori tecnici e logistici dell'Ente prendono in carico la segnalazione per l'immediata
istruttoria.
<br><br>
Il sistema gestisce l'intero flusso di stato: da <em>Aperta</em> a <em>In Revisione</em>, fino a
<em>In Lavorazione</em> e <em>Risolta</em>.
La piattaforma provvede a instradare automaticamente l'intervento alla squadra specializzata di
turno in base alla
categoria del problema e all'area geografica, garantendo un canale diretto per le comunicazioni di
servizio e aggiornando in tempo reale lo stato dei lavori.
</p>
</div>
</div>
@@ -87,14 +100,15 @@ useHead({
<div class="card card-bg h-100">
<div class="card-body">
<BIIcon class="mb-3" color="primary" name="it-settings" size="xl"/>
<h3 class="card-title h5 font-weight-bold">Amministrazione</h3>
<h3 class="card-title h5 font-weight-bold">L'Amministrazione</h3>
<p class="card-text text-muted">
Gli amministratori hanno il controllo completo della piattaforma. Possono definire le aree urbane
tramite poligoni GeoJSON, gestire i permessi degli utenti, supervisionare i feedback e moderare
eventuali abusi.
Gli amministratori dispongono del controllo completo sulle operazioni, con privilegi di gestione dei
profili e assegnazione delle nomine agli operatori.
<br><br>
Hanno inoltre accesso a statistiche avanzate per individuare le criticità sul territorio e
monitorare i tempi medi di risoluzione.
L'Ente utilizza funzioni di ricerca avanzata e filtraggio per area geografica per prevenire le
duplicazioni
e si avvale di strumenti analitici per il monitoraggio continuo dei tempi medi di risoluzione e
dell'efficienza dei servizi.
</p>
</div>
</div>
@@ -107,38 +121,50 @@ useHead({
<div class="container my-5 py-4">
<div class="row align-items-center">
<div class="col-12 col-lg-6 mb-4 mb-lg-0">
<h2 class="font-weight-bold mb-4">Tecnologia e Indipendenza</h2>
<div class="col-12 col-lg-7 mb-4 mb-lg-0">
<h2 class="font-weight-bold mb-4">Sicurezza, Centralità e Conformità</h2>
<p class="mb-4">Per soddisfare gli elevati standard richiesti da un Ente Pubblico, il sistema adotta precise
scelte organizzative e architetturali:</p>
<ul class="it-list">
<li>
<BIIcon class="me-2" color="primary" name="it-code-circle"/>
<strong>Architettura:</strong> Frontend reattivo e multipiattaforma sviluppato in <em>Vue.js/Nuxt 3</em>.
Backend ad altissime prestazioni scritto in <em>C++</em> utilizzando il framework <em>Drogon</em>.
<li class="d-flex align-items-start mb-3">
<BIIcon class="me-3 mt-1 flex-shrink-0" color="primary" name="it-pa"/>
<div>
<strong>Modello Centralizzato:</strong> Tutta la gestione logica e la persistenza dei dati risiedono
lato server, garantendo tracciabilità totale dei passaggi di stato e convalida rigorosa dei permessi
utente.
</div>
</li>
<li class="mt-3">
<BIIcon class="me-2" color="primary" name="it-lock"/>
<strong>Sicurezza:</strong> Accesso con OTP e sistema di autorizzazione Role-Based basato su token JWT.
<li class="d-flex align-items-start mb-3">
<BIIcon class="me-3 mt-1 flex-shrink-0" color="primary" name="it-lock"/>
<div>
<strong>Sicurezza e Privacy:</strong> Tutte le comunicazioni sono interamente cifrate (tramite rigidi
protocolli crittografici) per garantire la massima conformità alle normative sulla protezione dei dati
personali.
</div>
</li>
<li class="mt-3">
<BIIcon class="me-2" color="primary" name="it-piu"/>
<strong>Open Data:</strong> Piena aderenza alle linee guida AGID sulla trasparenza, con esportazione di
dati anonimizzati accessibili al pubblico.
<li class="d-flex align-items-start mb-3">
<BIIcon class="me-3 mt-1 flex-shrink-0" color="primary" name="it-mail"/>
<div>
<strong>Accesso Operatori:</strong> Il personale tecnico comunale accede al sistema tramite un approccio
sicuro basato su One-Time Password (OTP) inviata sull'e-mail aziendale.
</div>
</li>
</ul>
</div>
<div class="col-12 col-lg-5 offset-lg-1">
<div class="callout callout-highlight callout-warning">
<div class="col-12 col-lg-4 offset-lg-1">
<div class="callout callout-highlight callout-note">
<div class="callout-title">
<BIIcon name="it-warning-circle"/>
Un approccio etico al codice sorgente
<BIIcon name="it-info-circle"/>
Customer Satisfaction Integrata
</div>
<p>
In risposta alle policy sempre più aggressive delle grandi piattaforme commerciali riguardo
l'addestramento non consensuale di modelli di Intelligenza Artificiale sui repository privati, lo sviluppo
di questo progetto è ospitato su un'istanza <strong>self-hosted di Gitea</strong>.
La piattaforma non si esaurisce con l'intervento tecnico, ma chiude il ciclo con un modulo dedicato alla
<strong>Valutazione della Qualità</strong>.
<br><br>
Una scelta di principio per garantire il controllo totale, la privacy e la vera sovranità sui dati e sul
codice.
A risoluzione avvenuta, il cittadino viene invitato a esprimere un riscontro sulla tempestività e
sull'efficacia
del lavoro svolto, assicurando all'amministrazione un controllo qualità costante.
</p>
</div>
</div>
@@ -150,26 +176,34 @@ useHead({
<div class="card card-bg border-start border-primary border-5">
<div class="card-body">
<div class="row align-items-center">
<div class="col-12 col-md-8">
<h4 class="h5 font-weight-bold text-primary mb-2">Crediti Accademici</h4>
<div class="col-12 col-md-6 mb-4 mb-md-0">
<h4 class="h5 font-weight-bold text-primary mb-2">Il Team di Sviluppo</h4>
<p class="mb-1">
Questo progetto è stato realizzato nell'ambito del corso di <strong>Laboratorio di
Programmazione</strong> (tenuto dal Prof. Vessio).
</p>
<p class="text-muted small mb-0">
Università degli Studi di Bari "Aldo Moro" - Dipartimento di Informatica.
Questo portale è stato ideato e sviluppato per facilitare il percorso di transizione digitale
e modernizzare l'infrastruttura telematica a disposizione della cittadinanza.
</p>
</div>
<div class="col-12 col-md-4 mt-3 mt-md-0 text-md-end">
<p class="font-weight-bold mb-1">Sviluppato da:</p>
<ul class="list-unstyled mb-0">
<li>Renato Loiacono</li>
<li>Michelangelo Parisi</li>
<li>Gabriele Schiraldi</li>
</ul>
<div class="col-12 col-md-6">
<div class="d-flex flex-wrap justify-content-md-end justify-content-center gap-4">
<div
v-for="member in sortedMembers"
:key="member.name"
class="d-flex flex-column align-items-center text-center"
style="max-width: 90px;"
>
<img
:alt="member.name"
:src="member.image"
class="rounded-circle mb-2 shadow-sm"
style="width: 72px; height: 72px; object-fit: cover; border: 2px solid #0066cc;"
>
<span class="font-weight-bold small" style="line-height: 1.2;">{{ member.name }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -182,4 +216,8 @@ useHead({
list-style-type: none;
padding-left: 0;
}
h2 {
text-align: center;
}
</style>
+20 -42
View File
@@ -7,37 +7,27 @@ const { getOpenDataReports } = usePublic()
const currentPage = ref(1)
const perPage = ref(9)
const { data: openDataResponse, pending } = await useAsyncData(
'open-data-reports',
() => getOpenDataReports({ page: currentPage.value, per_page: perPage.value }),
{ watch: [currentPage] }
() => getOpenDataReports({ page: 1, per_page: 200 })
)
const visibleReports = computed(() =>
const allVisibleReports = computed(() =>
(openDataResponse.value?.reports || []).filter((report) => isWithinBariBounds(report.latitude, report.longitude))
)
const isLastPage = computed(() => {
if (!openDataResponse.value?.reports) return true
return openDataResponse.value.reports.length < perPage.value
const visibleReports = computed(() => {
const start = (currentPage.value - 1) * perPage.value
const end = start + perPage.value
return allVisibleReports.value.slice(start, end)
})
const nextPage = () => {
if (!isLastPage.value) currentPage.value++
}
const prevPage = () => {
if (currentPage.value > 1) currentPage.value--
const handlePageChange = (page: number) => {
currentPage.value = page
}
const { data: mapOpenDataResponse, pending: mapPending } = useAsyncData(
'open-data-map-reports',
() => getOpenDataReports({ page: 1, per_page: 200 }),
{ lazy: true }
)
const mapVisibleReports = computed(() =>
(mapOpenDataResponse.value?.reports || []).filter((report) => isWithinBariBounds(report.latitude, report.longitude))
)
const mapVisibleReports = computed(() => allVisibleReports.value)
const currentView = ref<'grid' | 'map'>('grid')
const mapContainer = ref<HTMLElement | null>(null)
@@ -170,7 +160,7 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="container my-5">
<div class="container my-5" id="grid-start">
<div class="row align-items-center mb-5 pb-3 border-bottom">
<div class="col-12 col-md-6 mb-3 mb-md-0 d-flex gap-2">
<button
@@ -205,7 +195,7 @@ onBeforeUnmount(() => {
</div>
<div v-show="!pending && currentView === 'grid'">
<div class="row" v-if="openDataResponse?.reports && openDataResponse.reports.length > 0">
<div class="row" v-if="allVisibleReports.length > 0">
<div class="col-12 col-md-6 col-lg-4 mb-4" v-for="report in visibleReports" :key="report.id">
<div class="card-wrapper card-space h-100">
<div class="card card-bg h-100 shadow-sm">
@@ -251,32 +241,20 @@ onBeforeUnmount(() => {
Nessun dato pubblico disponibile al momento.
</div>
<nav class="pagination-wrapper justify-content-center mt-5" aria-label="Navigazione pagine open data">
<ul class="pagination">
<li class="page-item" :class="{ disabled: currentPage === 1 }">
<a class="page-link" href="#" tabindex="-1" aria-disabled="true" @click.prevent="prevPage">
<BIIcon name="it-chevron-left" size="sm"/>
<span class="visually-hidden">Pagina precedente</span>
</a>
</li>
<li class="page-item">
<span class="page-link font-weight-semibold">Pagina {{ currentPage }}</span>
</li>
<li class="page-item" :class="{ disabled: isLastPage }">
<a class="page-link" href="#" @click.prevent="nextPage">
<span class="visually-hidden">Pagina successiva</span>
<BIIcon name="it-chevron-right" size="sm"/>
</a>
</li>
</ul>
</nav>
<Pagination
v-if="allVisibleReports.length > 0"
:current-page="currentPage"
:per-page="perPage"
:total-items="allVisibleReports.length"
@page-change="handlePageChange"
/>
</div>
<div v-show="!pending && currentView === 'map'" class="map-view-wrapper">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<p class="text-muted mb-0">Mappa pubblica delle segnalazioni nell'area di Bari.</p>
<span class="badge bg-light text-dark border">
<template v-if="mapPending">Caricamento...</template>
<template v-if="pending">Caricamento...</template>
<template v-else>{{ mapVisibleReports.length }} punti</template>
</span>
</div>
+71 -21
View File
@@ -16,7 +16,7 @@ if (!reportId) {
navigateTo('/operator')
}
const { getReportDetail, getMessages, addMessage, flagMessage, flagReport } = useReports()
const { getReportDetail, getMessages, addMessage, flagMessage, flagReport, getTeamSuggestions } = useReports()
const {
takeReport,
getInterventionSummary,
@@ -31,7 +31,9 @@ const { user } = useAuth()
const { data: reportData, refresh: refreshReport } = await useAsyncData(`op-report-${reportId}`, () => getReportDetail(reportId))
const { data: messagesData, refresh: refreshMessages } = await useAsyncData(`op-messages-${reportId}`, () => getMessages(reportId))
const { data: summaryData, refresh: refreshSummary } = await useAsyncData(`op-summary-${reportId}`, () => getInterventionSummary(reportId))
const { data: teamsData } = await useAsyncData('operator-teams', () => getTeams())
const { data: allTeamsData } = await useAsyncData('operator-teams', () => getTeams())
const { data: suggestedTeamsData } = await useAsyncData(`op-team-suggestions-${reportId}`, () => getTeamSuggestions(reportId))
const report = computed<Report | undefined>(() => (reportData.value as any)?.report)
const media = computed(() => (reportData.value as any)?.media || [])
@@ -39,7 +41,14 @@ const statusHistory = computed(() => (reportData.value as any)?.history || [])
const messages = computed<Message[]>(() => (messagesData.value as any)?.messages || [])
const summary = computed(() => (summaryData.value as any)?.summary)
const summaryMedia = computed(() => (summaryData.value as any)?.media || [])
const teams = computed(() => teamsData.value?.teams || [])
const allTeams = computed(() => (allTeamsData.value as any)?.teams || [])
const suggestedTeams = computed(() => (suggestedTeamsData.value as any)?.suggestions || [])
const otherTeams = computed(() => {
const suggestedIds = new Set(suggestedTeams.value.map((t: any) => Number(t.id)))
return allTeams.value.filter((t: any) => !suggestedIds.has(Number(t.id)))
})
const currentUserId = computed(() => user.value?.id ?? null)
const isTakenByCurrentOperator = computed(() => report.value?.assigned_operator_id === currentUserId.value)
@@ -47,7 +56,7 @@ const isOpenAndUnassigned = computed(() => report.value?.status === 'OPEN' && !r
const isReadOnlyPreview = computed(() => !isTakenByCurrentOperator.value)
const canTakeReport = computed(() => isOpenAndUnassigned.value && !isTakenByCurrentOperator.value)
const isFinalStatus = computed(() => ['REJECTED', 'RESOLVED', 'CLOSED'].includes(report.value?.status || ''))
const canAssignTeam = computed(() => !isReadOnlyPreview.value && !isFinalStatus.value && teams.value.length > 0)
const canAssignTeam = computed(() => !isReadOnlyPreview.value && !isFinalStatus.value && allTeams.value.length > 0)
const canSendMessage = computed(() => !isReadOnlyPreview.value && !isFinalStatus.value)
const canChangeStatus = computed(() => !isReadOnlyPreview.value && !isFinalStatus.value && selectableStatuses.value.length > 0)
@@ -131,6 +140,26 @@ const reportFlagForm = reactive<FlagRequest>({
details: ''
})
// === GESTIONE SCROLL SCHERMO CON MODALI ===
watch(
() => isAssignModalOpen.value || isFlagReportModalOpen.value || isRevokeInterventionModalOpen.value || isFlagMessageModalOpen.value,
(isOpen) => {
if (import.meta.client) {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
}
}
)
onUnmounted(() => {
if (import.meta.client) {
document.body.style.overflow = ''
}
})
watch(() => report.value?.status, (val) => {
if (val) {
const allowed = statusTransitions[val] || []
@@ -550,8 +579,8 @@ const formattedInterventionLinkExpiry = computed(() =>
<strong>{{ summary.assigned_operator_id ? `#${summary.assigned_operator_id}` : 'Nessuno' }}</strong>
</li>
<li>
<span>Team disponibili</span>
<strong>{{ teams.length }}</strong>
<span>Team suggeriti</span>
<strong :class="{'text-primary': suggestedTeams.length > 0}">{{ suggestedTeams.length }}</strong>
</li>
<li>
<span>Allegati</span>
@@ -688,32 +717,53 @@ const formattedInterventionLinkExpiry = computed(() =>
</div>
</div>
<div v-if="isAssignModalOpen" class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
<div v-if="isAssignModalOpen" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
<!-- BACKGROUND MODALE COMUNE -->
<div v-if="isAssignModalOpen || isFlagReportModalOpen" class="modal-backdrop fade show d-block"
style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
<!-- MODALE: GESTIONE INTERVENTO -->
<div v-if="isAssignModalOpen" class="modal fade show d-block" style="z-index: 1050; overflow-y: auto;"
tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Gestione Intervento</h5>
<button type="button" class="btn-close" @click="isAssignModalOpen = false"></button>
</div>
<div class="modal-body">
<!-- FORM AGGIORNATO CON MODAL-BODY E MODAL-FOOTER -->
<form @submit.prevent="handleAssignTeam">
<div class="mb-3">
<div class="modal-body">
<div class="mb-0">
<label class="form-label">Team incaricato</label>
<select v-model.number="selectedTeamId" class="form-select" required>
<option :value="null">Seleziona team</option>
<option v-for="team in teams" :key="team.id" :value="team.id">
{{ team.name }}<template v-if="team.category_name"> · {{ team.category_name }}</template>
<optgroup v-if="suggestedTeams.length > 0" label="Team Suggeriti">
<option v-for="team in suggestedTeams" :key="'sug-' + team.id" :value="Number(team.id)">
{{ team.name }}
<template v-if="team.is_recommended"> ★</template>
<template v-if="team.category_name"> · {{ team.category_name }}</template>
<template v-if="team.area_name"> · {{ team.area_name }}</template>
</option>
</optgroup>
<optgroup :label="suggestedTeams.length > 0 ? 'Altri Team' : 'Tutti i Team'">
<option v-for="team in otherTeams" :key="'oth-' + team.id" :value="Number(team.id)">
{{ team.name }}
<template v-if="team.category_name"> · {{ team.category_name }}</template>
<template v-if="team.area_name"> · {{ team.area_name }}</template>
</option>
</optgroup>
</select>
<small v-if="teams.length === 0" class="form-text text-danger">
<small v-if="allTeams.length === 0" class="form-text text-danger mt-1">
Nessun team attivo disponibile.
</small>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<button type="button" class="btn btn-outline-secondary" @click="isAssignModalOpen = false">Annulla</button>
<button type="submit" class="btn btn-primary" :disabled="isAssigning || !selectedTeamId">
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary" type="button" @click="isAssignModalOpen = false">Annulla
</button>
<button :disabled="isAssigning || !selectedTeamId" class="btn btn-primary" type="submit">
<span v-if="isAssigning" class="spinner-border spinner-border-sm me-2"></span>
Salva Assegnazione
</button>
@@ -722,14 +772,14 @@ const formattedInterventionLinkExpiry = computed(() =>
</div>
</div>
</div>
</div>
<div v-if="isFlagReportModalOpen" class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
<div v-if="isFlagReportModalOpen" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
<!-- MODALE: SEGNALA ALLA MODERAZIONE -->
<div v-if="isFlagReportModalOpen" class="modal fade show d-block" style="z-index: 1050; overflow-y: auto;"
tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Segnala segnalazione alla moderazione</h5>
<h5 class="modal-title">Segnala alla moderazione</h5>
<button type="button" class="btn-close" :disabled="isFlaggingReport" @click="isFlagReportModalOpen = false; resetReportFlagForm()"></button>
</div>
<div class="modal-body">
@@ -738,7 +788,7 @@ const formattedInterventionLinkExpiry = computed(() =>
<label class="form-label">Motivo</label>
<input v-model="reportFlagForm.reason" type="text" class="form-control" maxlength="200" />
</div>
<div>
<div class="mb-0">
<label class="form-label">Dettagli</label>
<textarea v-model="reportFlagForm.details" class="form-control" rows="4" placeholder="Dettagli aggiuntivi opzionali"></textarea>
</div>
+49 -4
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
definePageMeta({
middleware: 'auth',
})
@@ -34,6 +36,19 @@ const isFeedbackOpen = ref(false)
const feedbackRating = ref(5)
const feedbackComment = ref('')
const isSubmittingFeedback = ref(false)
const submittedFeedback = ref<{ rating: number; comment?: string } | null>(null)
// Carica l'eventuale feedback da local storage dopo il mount (per evitare mismatch nel SSR)
onMounted(() => {
try {
const saved = localStorage.getItem(`feedback_${reportId}`)
if (saved) {
submittedFeedback.value = JSON.parse(saved)
}
} catch (error) {
console.error('Errore nel recupero del feedback da local storage:', error)
}
})
const handleSendMessage = async (messageText: string, files: File[]) => {
isSubmittingMessage.value = true
@@ -51,13 +66,18 @@ const handleSendMessage = async (messageText: string, files: File[]) => {
const handleSubmitFeedback = async () => {
isSubmittingFeedback.value = true
try {
await addFeedback(reportId, {
const feedbackData = {
rating: feedbackRating.value,
comment: feedbackComment.value || undefined,
})
}
await addFeedback(reportId, feedbackData)
// Salva in local storage in mancanza di un endpoint dal backend
localStorage.setItem(`feedback_${reportId}`, JSON.stringify(feedbackData))
submittedFeedback.value = feedbackData
isFeedbackOpen.value = false
feedbackRating.value = 5
feedbackComment.value = ''
await refreshReport()
} catch (error) {
console.error('Error submitting feedback:', error)
@@ -251,6 +271,30 @@ const formatDate = (dateString: string): string => {
<div v-if="canFeedback && isOwner" class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h6 mb-3">Valuta intervento</h2>
<template v-if="submittedFeedback">
<div class="alert alert-success small mb-3">
Grazie! Il tuo feedback è stato inviato con successo.
</div>
<div class="mb-3">
<span class="d-block text-muted small mb-1">La tua valutazione</span>
<div class="d-flex gap-1">
<BIIcon
v-for="n in 5"
:key="n"
:name="n <= submittedFeedback.rating ? 'it-star-full' : 'it-star-outline'"
size="sm"
:color="n <= submittedFeedback.rating ? 'warning' : 'primary'"
/>
</div>
</div>
<div v-if="submittedFeedback.comment">
<span class="d-block text-muted small mb-1">Il tuo commento</span>
<p class="mb-0 small text-break">{{ submittedFeedback.comment }}</p>
</div>
</template>
<template v-else>
<p class="text-muted small mb-3">Lascia valutazione finale quando intervento è concluso.</p>
<button
v-if="!isFeedbackOpen"
@@ -296,6 +340,7 @@ const formatDate = (dateString: string): string => {
</button>
</div>
</template>
</template>
</div>
</div>
+42 -3
View File
@@ -6,10 +6,11 @@ export default defineNuxtConfig({
'@nuxt/image',
'@nuxt/fonts',
'@nuxtjs/sitemap',
'@nuxtjs/robots'
'@nuxtjs/robots',
'@vite-pwa/nuxt'
],
// TODO: riattivare prima di pubblicare il sito
ssr: false,
ssr: true,
runtimeConfig: {
public: {
@@ -39,9 +40,47 @@ export default defineNuxtConfig({
app: {
head: {
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0' },
{ name: 'theme-color', content: '#003366' },
{ name: 'description', content: 'Visione - Un\'occhio per la sicurezza' },
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
{ name: 'apple-mobile-web-app-title', content: 'Visione' }
],
link: [
{ rel: 'icon', type: 'image/svg+xml', href: '/icon.svg' }
]
}
},
pwa: {
registerType: 'autoUpdate',
manifest: {
name: 'Visione - Un\'occhio per la sicurezza',
short_name: 'Visione',
theme_color: '#003366',
background_color: '#003366',
display: 'standalone',
orientation: 'portrait',
icons: [
{
src: '/icon-192.webp',
sizes: '192x192',
type: 'image/webp',
purpose: 'any'
},
{
src: '/icon-512.webp',
sizes: '512x512',
type: 'image/webp',
purpose: 'any'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,png,svg,ico,webp}']
}
}
})
+1
View File
@@ -14,6 +14,7 @@
"@nuxt/image": "^2.0.0",
"@nuxtjs/robots": "6.0.9",
"@nuxtjs/sitemap": "8.0.15",
"@vite-pwa/nuxt": "1.1.1",
"bootstrap-italia": "^2.18.1",
"codice-fiscale-js": "^2.3.23",
"leaflet": "^1.9.4",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

+3195 -211
View File
File diff suppressed because it is too large Load Diff