Add Symfony payload mapping, fixtures, and QA tooling
This commit is contained in:
+30
-62
@@ -1,75 +1,43 @@
|
|||||||
# React + TypeScript + Vite
|
# SignalMap client
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
A collaborative danger zone explorer built with React, Vite, and Leaflet. Crowd members can drop signals on an OpenStreetMap base layer; the UI highlights the hottest areas as a heatmap and keeps track of active contributors without any authentication.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
## Getting started
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
1. Install dependencies (Node.js 20+ recommended):
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## React Compiler
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
|
2. Start the Vite dev server:
|
||||||
|
|
||||||
Note: This will impact Vite dev & build performances.
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
The app assumes the API is available at `http://localhost:8000/api.php`. You can override this by setting `VITE_API_BASE` in an `.env` file.
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
3. Start the lightweight PHP backend (from the repository root):
|
||||||
|
|
||||||
```js
|
```bash
|
||||||
export default defineConfig([
|
php -S 0.0.0.0:8000 -t server/public
|
||||||
globalIgnores(['dist']),
|
```
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
The backend stores data in `server/var/points.sqlite` (ignored by git). It identifies users by IP address and provides aggregated heatmap cells plus contributor statistics.
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
## Features
|
||||||
],
|
|
||||||
languageOptions: {
|
- Leaflet + OpenStreetMap map canvas with a live heat layer (via `leaflet.heat`).
|
||||||
parserOptions: {
|
- Click-to-report interaction that drops a signal at the clicked coordinates.
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
- Heatmap aggregation and "danger zone" overlays that spotlight the busiest cells.
|
||||||
tsconfigRootDir: import.meta.dirname,
|
- Live contributor feed showing the most recent pings and top spotters.
|
||||||
},
|
- Accessible, shadcn-inspired UI components without any authentication requirement.
|
||||||
// other options...
|
|
||||||
},
|
## Production build
|
||||||
},
|
|
||||||
])
|
```bash
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
The output will be placed in `dist/`. Serve it with your favourite static host while keeping the PHP API reachable at `/api.php`.
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|||||||
+17
-1
@@ -4,10 +4,26 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>client</title>
|
<title>SignalMap | Collaborative Hotspot Tracker</title>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
integrity="sha512-sA+e2u1j7mG2mZHg1F9n3u1kVpWwfvX2gYdEx+kt1/3uzMdGII4XESyqSeX5p1+t0NenE2no0LYh3R1n8z+Gxw=="
|
||||||
|
crossorigin=""
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<script
|
||||||
|
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||||
|
integrity="sha512-vPO0bXiC7hlHLLANkRb114F8CbnMD4HzyBbs6k8ZZr68Su2Ce279b9EcRWSavwgJeayQMZT6BdS8wP3r3MJ5iw=="
|
||||||
|
crossorigin=""
|
||||||
|
></script>
|
||||||
|
<script
|
||||||
|
src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"
|
||||||
|
integrity="sha384-24E8AI6UK4SlHe/BUOMwfc9yqD0nV9vsxjMSb7ElJ+PmTWk1FLaeL7XggVzfl8z9"
|
||||||
|
crossorigin=""
|
||||||
|
></script>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+1131
-3
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -10,8 +10,12 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1"
|
"react-dom": "^19.1.1",
|
||||||
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
@@ -19,11 +23,15 @@
|
|||||||
"@types/react": "^19.1.16",
|
"@types/react": "^19.1.16",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
"babel-plugin-react-compiler": "^19.1.0-rc.3",
|
"babel-plugin-react-compiler": "^19.1.0-rc.3",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.36.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.22",
|
"eslint-plugin-react-refresh": "^0.4.22",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.4.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.45.0",
|
"typescript-eslint": "^8.45.0",
|
||||||
"vite": "npm:rolldown-vite@7.1.14"
|
"vite": "npm:rolldown-vite@7.1.14"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#root {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 6em;
|
|
||||||
padding: 1.5em;
|
|
||||||
will-change: filter;
|
|
||||||
transition: filter 300ms;
|
|
||||||
}
|
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
|
||||||
}
|
|
||||||
.logo.react:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logo-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
a:nth-of-type(2) .logo {
|
|
||||||
animation: logo-spin infinite 20s linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
+737
-31
@@ -1,35 +1,741 @@
|
|||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import reactLogo from './assets/react.svg'
|
import { Badge } from '@ui/badge'
|
||||||
import viteLogo from '/vite.svg'
|
import { Button } from '@ui/button'
|
||||||
import './App.css'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@ui/card'
|
||||||
|
import { Separator } from '@ui/separator'
|
||||||
|
import { cn, distanceInKm, formatCoordinate, formatRelativeTime } from '@lib/utils'
|
||||||
|
|
||||||
function App() {
|
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals'
|
||||||
const [count, setCount] = useState(0)
|
const VISIBLE_RADIUS_KM = 5
|
||||||
|
|
||||||
return (
|
type Status = 'loading' | 'idle' | 'error' | 'posting' | 'refreshing'
|
||||||
<>
|
|
||||||
<div>
|
type ApiPoint = {
|
||||||
<a href="https://vite.dev" target="_blank">
|
id: number
|
||||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
lat: number
|
||||||
</a>
|
lng: number
|
||||||
<a href="https://react.dev" target="_blank">
|
createdAt: string
|
||||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
userKey: string
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<h1>Vite + React</h1>
|
|
||||||
<div className="card">
|
|
||||||
<button onClick={() => setCount((count) => count + 1)}>
|
|
||||||
count is {count}
|
|
||||||
</button>
|
|
||||||
<p>
|
|
||||||
Edit <code>src/App.tsx</code> and save to test HMR
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="read-the-docs">
|
|
||||||
Click on the Vite and React logos to learn more
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
type ApiDensity = {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
intensity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiResponse = {
|
||||||
|
clientKey?: string
|
||||||
|
points?: ApiPoint[]
|
||||||
|
density?: ApiDensity[]
|
||||||
|
latestByUser?: ApiPoint[]
|
||||||
|
totals?: {
|
||||||
|
points: number
|
||||||
|
contributors: number
|
||||||
|
}
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LatLng {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: Status): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'loading':
|
||||||
|
return 'Syncing map'
|
||||||
|
case 'posting':
|
||||||
|
return 'Sending your signal'
|
||||||
|
case 'refreshing':
|
||||||
|
return 'Updating hotspots'
|
||||||
|
case 'error':
|
||||||
|
return 'Offline'
|
||||||
|
default:
|
||||||
|
return 'Live feed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function geolocationErrorMessage(error: GeolocationPositionError): string {
|
||||||
|
switch (error.code) {
|
||||||
|
case error.PERMISSION_DENIED:
|
||||||
|
return 'Location access denied. Enable it to view nearby pings.'
|
||||||
|
case error.POSITION_UNAVAILABLE:
|
||||||
|
return 'Unable to determine your position. Try again.'
|
||||||
|
case error.TIMEOUT:
|
||||||
|
return 'Timed out while fetching your location.'
|
||||||
|
default:
|
||||||
|
return 'Failed to retrieve your location.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const mapRef = useRef<any>(null)
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const heatLayerRef = useRef<any>(null)
|
||||||
|
const markersLayerRef = useRef<any>(null)
|
||||||
|
const zonesLayerRef = useRef<any>(null)
|
||||||
|
const userLayerRef = useRef<any>(null)
|
||||||
|
const locationWatchIdRef = useRef<number | null>(null)
|
||||||
|
const statusRef = useRef<Status>('loading')
|
||||||
|
const initialLoadRef = useRef(true)
|
||||||
|
const hasCenteredOnUserRef = useRef(false)
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<Status>('loading')
|
||||||
|
const [rawPoints, setRawPoints] = useState<ApiPoint[]>([])
|
||||||
|
const [rawDensity, setRawDensity] = useState<ApiDensity[]>([])
|
||||||
|
const [rawLatestByUser, setRawLatestByUser] = useState<ApiPoint[]>([])
|
||||||
|
const [clientKey, setClientKey] = useState<string | null>(null)
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<string | null>(null)
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
const [userLocation, setUserLocation] = useState<LatLng | null>(null)
|
||||||
|
const [locationError, setLocationError] = useState<string | null>(null)
|
||||||
|
const [isRequestingLocation, setIsRequestingLocation] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const setStatusSafe = useCallback((next: Status) => {
|
||||||
|
statusRef.current = next
|
||||||
|
setStatus(next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startLocationWatch = useCallback(() => {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||||
|
setLocationError('Geolocation is not supported in this browser.')
|
||||||
|
setIsRequestingLocation(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRequestingLocation(true)
|
||||||
|
setLocationError(null)
|
||||||
|
|
||||||
|
if (locationWatchIdRef.current !== null) {
|
||||||
|
navigator.geolocation.clearWatch(locationWatchIdRef.current)
|
||||||
|
locationWatchIdRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
setUserLocation({ lat: position.coords.latitude, lng: position.coords.longitude })
|
||||||
|
setIsRequestingLocation(false)
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
setLocationError(geolocationErrorMessage(error))
|
||||||
|
setIsRequestingLocation(false)
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const watchId = navigator.geolocation.watchPosition(
|
||||||
|
(position) => {
|
||||||
|
setUserLocation({ lat: position.coords.latitude, lng: position.coords.longitude })
|
||||||
|
setLocationError(null)
|
||||||
|
setIsRequestingLocation(false)
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
setLocationError(geolocationErrorMessage(error))
|
||||||
|
setIsRequestingLocation(false)
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, maximumAge: 15000, timeout: 10000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
locationWatchIdRef.current = watchId
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchSnapshot = useCallback(
|
||||||
|
async (options?: { silent?: boolean }) => {
|
||||||
|
const silent = options?.silent ?? false
|
||||||
|
const previousStatus = statusRef.current
|
||||||
|
const isInitial = initialLoadRef.current
|
||||||
|
|
||||||
|
if (previousStatus !== 'posting') {
|
||||||
|
if (isInitial) {
|
||||||
|
setStatusSafe('loading')
|
||||||
|
} else if (!silent) {
|
||||||
|
setStatusSafe('refreshing')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}?limit=750`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Unable to reach the hotspot feed.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: ApiResponse = await response.json()
|
||||||
|
setRawPoints(data.points ?? [])
|
||||||
|
setRawDensity(data.density ?? [])
|
||||||
|
setRawLatestByUser(data.latestByUser ?? [])
|
||||||
|
setClientKey(data.clientKey ?? null)
|
||||||
|
setLastUpdated(data.updatedAt ?? new Date().toISOString())
|
||||||
|
setErrorMessage(null)
|
||||||
|
initialLoadRef.current = false
|
||||||
|
|
||||||
|
const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle'
|
||||||
|
setStatusSafe(nextStatus)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error while loading hotspots.'
|
||||||
|
setErrorMessage(message)
|
||||||
|
if (initialLoadRef.current) {
|
||||||
|
setStatusSafe('error')
|
||||||
|
} else if (previousStatus !== 'posting') {
|
||||||
|
setStatusSafe('idle')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setStatusSafe],
|
||||||
|
)
|
||||||
|
|
||||||
|
const submitPoint = useCallback(
|
||||||
|
async (lat: number, lng: number) => {
|
||||||
|
setStatusSafe('posting')
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_BASE, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ lat, lng }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null)
|
||||||
|
const message = payload?.message ?? 'Unable to store your signal.'
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchSnapshot({ silent: true })
|
||||||
|
setStatusSafe('idle')
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Something went wrong while saving your signal.'
|
||||||
|
setErrorMessage(message)
|
||||||
|
setStatusSafe('error')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchSnapshot, setStatusSafe],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleMapClick = useCallback(
|
||||||
|
({ lat, lng }: LatLng) => {
|
||||||
|
submitPoint(lat, lng).catch(() => {})
|
||||||
|
},
|
||||||
|
[submitPoint],
|
||||||
|
)
|
||||||
|
|
||||||
|
const initialiseMap = useCallback(() => {
|
||||||
|
if (mapRef.current || !mapContainerRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaflet = (window as any).L
|
||||||
|
if (!leaflet) {
|
||||||
|
setErrorMessage('Leaflet failed to load. Refresh the page to try again.')
|
||||||
|
setStatusSafe('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = leaflet
|
||||||
|
.map(mapContainerRef.current, {
|
||||||
|
worldCopyJump: true,
|
||||||
|
minZoom: 2,
|
||||||
|
zoomControl: true,
|
||||||
|
})
|
||||||
|
.setView([20, 0], 2)
|
||||||
|
|
||||||
|
leaflet
|
||||||
|
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
crossOrigin: true,
|
||||||
|
maxZoom: 19,
|
||||||
|
})
|
||||||
|
.addTo(map)
|
||||||
|
|
||||||
|
const heatLayer = typeof leaflet.heatLayer === 'function'
|
||||||
|
? leaflet.heatLayer([], {
|
||||||
|
radius: 32,
|
||||||
|
blur: 24,
|
||||||
|
maxZoom: 12,
|
||||||
|
gradient: {
|
||||||
|
0.2: '#38bdf8',
|
||||||
|
0.4: '#0ea5e9',
|
||||||
|
0.6: '#fbbf24',
|
||||||
|
0.8: '#f97316',
|
||||||
|
1.0: '#ef4444',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
const markersLayer = leaflet.layerGroup().addTo(map)
|
||||||
|
const zonesLayer = leaflet.layerGroup().addTo(map)
|
||||||
|
const userLayer = leaflet.layerGroup().addTo(map)
|
||||||
|
|
||||||
|
if (heatLayer) {
|
||||||
|
heatLayer.addTo(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = (event: any) => {
|
||||||
|
const { lat, lng } = event.latlng ?? {}
|
||||||
|
if (typeof lat === 'number' && typeof lng === 'number') {
|
||||||
|
handleMapClick({ lat, lng })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on('click', onClick)
|
||||||
|
|
||||||
|
map.whenReady(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
map.invalidateSize()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
map.invalidateSize()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
|
||||||
|
mapRef.current = map
|
||||||
|
heatLayerRef.current = heatLayer
|
||||||
|
markersLayerRef.current = markersLayer
|
||||||
|
zonesLayerRef.current = zonesLayer
|
||||||
|
userLayerRef.current = userLayer
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('click', onClick)
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
|
map.remove()
|
||||||
|
mapRef.current = null
|
||||||
|
heatLayerRef.current = null
|
||||||
|
markersLayerRef.current = null
|
||||||
|
zonesLayerRef.current = null
|
||||||
|
userLayerRef.current = null
|
||||||
|
}
|
||||||
|
}, [handleMapClick, setStatusSafe])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = initialiseMap()
|
||||||
|
const sizeTimer = window.setTimeout(() => {
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.invalidateSize()
|
||||||
|
}
|
||||||
|
}, 150)
|
||||||
|
fetchSnapshot().catch(() => {})
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
fetchSnapshot({ silent: true }).catch(() => {})
|
||||||
|
}, 7000)
|
||||||
|
|
||||||
|
startLocationWatch()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (typeof cleanup === 'function') {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
window.clearTimeout(sizeTimer)
|
||||||
|
window.clearInterval(interval)
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.geolocation && locationWatchIdRef.current !== null) {
|
||||||
|
navigator.geolocation.clearWatch(locationWatchIdRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fetchSnapshot, initialiseMap, startLocationWatch])
|
||||||
|
|
||||||
|
const visibleDensity = useMemo(() => {
|
||||||
|
if (!userLocation) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return rawDensity.filter((entry) => distanceInKm(userLocation, entry) <= VISIBLE_RADIUS_KM)
|
||||||
|
}, [rawDensity, userLocation])
|
||||||
|
|
||||||
|
const visibleDangerZones = useMemo(() => {
|
||||||
|
if (!visibleDensity.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return visibleDensity.slice(0, 3)
|
||||||
|
}, [visibleDensity])
|
||||||
|
|
||||||
|
const visiblePoints = useMemo(() => {
|
||||||
|
if (!userLocation) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return rawPoints.filter((point) => distanceInKm(userLocation, point) <= VISIBLE_RADIUS_KM)
|
||||||
|
}, [rawPoints, userLocation])
|
||||||
|
|
||||||
|
const visibleLatestByUser = useMemo(() => {
|
||||||
|
if (!userLocation) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return rawLatestByUser.filter((point) => distanceInKm(userLocation, point) <= VISIBLE_RADIUS_KM)
|
||||||
|
}, [rawLatestByUser, userLocation])
|
||||||
|
|
||||||
|
const localTotals = useMemo(() => {
|
||||||
|
const uniqueUsers = new Set<string>()
|
||||||
|
visibleLatestByUser.forEach((point) => uniqueUsers.add(point.userKey))
|
||||||
|
|
||||||
|
return {
|
||||||
|
points: visiblePoints.length,
|
||||||
|
contributors: uniqueUsers.size,
|
||||||
|
}
|
||||||
|
}, [visibleLatestByUser, visiblePoints])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const heatLayer = heatLayerRef.current
|
||||||
|
const leaflet = (window as any).L
|
||||||
|
if (!heatLayer || !leaflet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visibleDensity.length) {
|
||||||
|
heatLayer.setLatLngs([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxIntensity = Math.max(...visibleDensity.map((entry) => entry.intensity)) || 1
|
||||||
|
const heatPoints = visibleDensity.map((entry) => [entry.lat, entry.lng, Math.max(0.25, entry.intensity / maxIntensity)])
|
||||||
|
heatLayer.setLatLngs(heatPoints)
|
||||||
|
}, [visibleDensity])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = zonesLayerRef.current
|
||||||
|
const leaflet = (window as any).L
|
||||||
|
if (!layer || !leaflet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.clearLayers()
|
||||||
|
|
||||||
|
if (!visibleDangerZones.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxIntensity = visibleDangerZones[0]?.intensity ?? 0
|
||||||
|
|
||||||
|
visibleDangerZones.forEach((zone, index) => {
|
||||||
|
const intensityRatio = maxIntensity ? zone.intensity / maxIntensity : 0.5
|
||||||
|
const radius = 400 + intensityRatio * 1600
|
||||||
|
const circle = leaflet.circle([zone.lat, zone.lng], {
|
||||||
|
radius,
|
||||||
|
color: index === 0 ? '#ef4444' : '#f97316',
|
||||||
|
weight: 2,
|
||||||
|
fillColor: '#ef4444',
|
||||||
|
fillOpacity: Math.max(0.12, 0.22 - index * 0.04),
|
||||||
|
})
|
||||||
|
|
||||||
|
circle.addTo(layer).bindTooltip(`Hotspot #${index + 1}\nIntensity: ${zone.intensity}`, {
|
||||||
|
direction: 'top',
|
||||||
|
offset: [0, -12],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [visibleDangerZones])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = markersLayerRef.current
|
||||||
|
const leaflet = (window as any).L
|
||||||
|
if (!layer || !leaflet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.clearLayers()
|
||||||
|
|
||||||
|
const latestMap = new Map<string, ApiPoint>()
|
||||||
|
visibleLatestByUser.forEach((point) => {
|
||||||
|
const existing = latestMap.get(point.userKey)
|
||||||
|
if (!existing || existing.createdAt < point.createdAt) {
|
||||||
|
latestMap.set(point.userKey, point)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
latestMap.forEach((point) => {
|
||||||
|
const isSelf = clientKey && point.userKey === clientKey
|
||||||
|
const marker = leaflet.circleMarker([point.lat, point.lng], {
|
||||||
|
radius: isSelf ? 10 : 6,
|
||||||
|
color: isSelf ? '#38bdf8' : '#94a3b8',
|
||||||
|
weight: isSelf ? 3 : 1.5,
|
||||||
|
opacity: 0.9,
|
||||||
|
fillOpacity: isSelf ? 0.45 : 0.28,
|
||||||
|
fillColor: isSelf ? '#38bdf8' : '#cbd5f5',
|
||||||
|
})
|
||||||
|
|
||||||
|
marker.addTo(layer).bindTooltip(
|
||||||
|
`User ${point.userKey}\n${formatCoordinate(point.lat)}, ${formatCoordinate(point.lng)}`,
|
||||||
|
{
|
||||||
|
direction: 'top',
|
||||||
|
offset: [0, -8],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [visibleLatestByUser, clientKey])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = userLayerRef.current
|
||||||
|
const leaflet = (window as any).L
|
||||||
|
if (!layer || !leaflet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.clearLayers()
|
||||||
|
|
||||||
|
if (!userLocation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
leaflet
|
||||||
|
.circle([userLocation.lat, userLocation.lng], {
|
||||||
|
radius: VISIBLE_RADIUS_KM * 1000,
|
||||||
|
color: '#38bdf8',
|
||||||
|
weight: 1.5,
|
||||||
|
opacity: 0.6,
|
||||||
|
dashArray: '6 6',
|
||||||
|
fillColor: '#38bdf8',
|
||||||
|
fillOpacity: 0.05,
|
||||||
|
})
|
||||||
|
.addTo(layer)
|
||||||
|
|
||||||
|
leaflet
|
||||||
|
.circleMarker([userLocation.lat, userLocation.lng], {
|
||||||
|
radius: 7,
|
||||||
|
color: '#38bdf8',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 0.9,
|
||||||
|
fillColor: '#38bdf8',
|
||||||
|
fillOpacity: 0.5,
|
||||||
|
})
|
||||||
|
.addTo(layer)
|
||||||
|
}, [userLocation])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || !userLocation || hasCenteredOnUserRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mapRef.current.setView([userLocation.lat, userLocation.lng], 12, {
|
||||||
|
animate: true,
|
||||||
|
})
|
||||||
|
hasCenteredOnUserRef.current = true
|
||||||
|
}, [userLocation])
|
||||||
|
|
||||||
|
const recentActivity = useMemo(() => visiblePoints.slice(0, 8), [visiblePoints])
|
||||||
|
const statusLabel = getStatusLabel(status)
|
||||||
|
const statusBadgeClass = cn(
|
||||||
|
'inline-flex items-center gap-2 rounded-full border border-border/60 bg-secondary/50 px-3 py-1 text-xs font-medium tracking-wide text-muted-foreground backdrop-blur',
|
||||||
|
status === 'error' && 'border-destructive/40 bg-destructive/15 text-destructive',
|
||||||
|
)
|
||||||
|
const statusAccentClass = status === 'error' ? 'text-destructive' : 'text-primary'
|
||||||
|
const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated) : 'never'
|
||||||
|
const isLoading = status === 'loading'
|
||||||
|
const isPosting = status === 'posting'
|
||||||
|
const heatMax = visibleDangerZones[0]?.intensity ?? 0
|
||||||
|
const hasLocation = Boolean(userLocation)
|
||||||
|
const myLatestPoint = useMemo(() => {
|
||||||
|
if (!clientKey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const candidate = rawLatestByUser.find((point) => point.userKey === clientKey)
|
||||||
|
if (!candidate) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (userLocation && distanceInKm(userLocation, candidate) > VISIBLE_RADIUS_KM) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}, [rawLatestByUser, clientKey, userLocation])
|
||||||
|
|
||||||
|
const focusDangerZone = useCallback(() => {
|
||||||
|
if (!mapRef.current || !visibleDangerZones.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const zone = visibleDangerZones[0]
|
||||||
|
mapRef.current.setView([zone.lat, zone.lng], 13, {
|
||||||
|
animate: true,
|
||||||
|
})
|
||||||
|
}, [visibleDangerZones])
|
||||||
|
|
||||||
|
const focusMySignal = useCallback(() => {
|
||||||
|
if (!mapRef.current || !myLatestPoint) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mapRef.current.setView([myLatestPoint.lat, myLatestPoint.lng], 14, {
|
||||||
|
animate: true,
|
||||||
|
})
|
||||||
|
}, [myLatestPoint])
|
||||||
|
|
||||||
|
const refreshNow = useCallback(() => {
|
||||||
|
fetchSnapshot().catch(() => {})
|
||||||
|
}, [fetchSnapshot])
|
||||||
|
|
||||||
|
const locationHint = locationError
|
||||||
|
? locationError
|
||||||
|
: hasLocation
|
||||||
|
? `Showing reports within ${VISIBLE_RADIUS_KM}km of you.`
|
||||||
|
: isRequestingLocation
|
||||||
|
? 'Fetching your location...'
|
||||||
|
: 'Allow location access to view nearby danger pings.'
|
||||||
|
|
||||||
|
const showLocationCta = !hasLocation || Boolean(locationError)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border/60 bg-background/70 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-4 px-6 py-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-xl font-semibold sm:text-2xl">SignalMap</h1>
|
||||||
|
<p className="text-sm text-muted-foreground sm:text-base">
|
||||||
|
Crowd-powered danger zones layered on Leaflet + OpenStreetMap.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={statusBadgeClass} aria-live="polite">
|
||||||
|
<span className={cn('flex items-center gap-2', statusAccentClass)}>
|
||||||
|
<span className="status-dot relative block h-2.5 w-2.5 rounded-full bg-[currentColor]" aria-hidden />
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" onClick={refreshNow} disabled={isLoading || status === 'refreshing' || isPosting}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button onClick={focusDangerZone} disabled={!visibleDangerZones.length}>
|
||||||
|
Focus danger zone
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={focusMySignal} disabled={!myLatestPoint}>
|
||||||
|
Locate me
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-6 py-6 lg:flex-row">
|
||||||
|
<section className="flex w-full flex-col gap-4 lg:max-w-sm">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Danger zone intel</CardTitle>
|
||||||
|
<CardDescription>Highest intensity cells near you right now.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{!hasLocation && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
We need your location to reveal community alerts around you.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hasLocation && visibleDangerZones.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No hotspots within {VISIBLE_RADIUS_KM}km yet. Tap anywhere on the map to raise the first signal.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{visibleDangerZones.map((zone, index) => (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between rounded-lg border border-border/60 bg-muted/20 px-3 py-2"
|
||||||
|
key={`${zone.lat}-${zone.lng}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Hotspot #{index + 1}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatCoordinate(zone.lat)}, {formatCoordinate(zone.lng)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-orange-400">×{zone.intensity}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
{hasLocation ? (
|
||||||
|
<span>
|
||||||
|
Heatmap max intensity: {heatMax || '—'} • Last sync {lastUpdatedLabel}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>Totals unavailable until we can access your position.</span>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Community feed</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Local reports: {localTotals.points} · Unique spotters: {localTotals.contributors}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{!hasLocation && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Allow location access to tune the feed to your area.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hasLocation && recentActivity.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Waiting for the first signals nearby. Click the map to broadcast a hazard ping.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{recentActivity.map((item) => (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-1 rounded-lg border border-border/60 bg-card/40 px-3 py-2"
|
||||||
|
key={item.id}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{formatCoordinate(item.lat)}, {formatCoordinate(item.lng)}
|
||||||
|
</span>
|
||||||
|
<Badge variant={item.userKey === clientKey ? 'success' : 'muted'}>
|
||||||
|
{item.userKey === clientKey ? 'You' : `User ${item.userKey}`}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">{formatRelativeTime(item.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex w-full flex-1 flex-col gap-4">
|
||||||
|
{status === 'error' && errorMessage && (
|
||||||
|
<div className="flex items-center justify-between rounded-xl border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive shadow-lg shadow-destructive/20">
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
<Button variant="ghost" onClick={refreshNow} className="text-destructive hover:text-destructive">
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative flex min-h-[480px] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-card/70 shadow-2xl shadow-black/40">
|
||||||
|
<div
|
||||||
|
ref={mapContainerRef}
|
||||||
|
className="h-full min-h-[480px] w-full"
|
||||||
|
aria-label="Collaborative danger zone map"
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute bottom-4 left-4 right-4 mx-auto flex max-w-md flex-col gap-3 rounded-2xl border border-border/60 bg-background/90 p-4 text-xs text-muted-foreground shadow-xl shadow-black/50 backdrop-blur">
|
||||||
|
<div className="flex items-center justify-between text-[0.8rem] text-foreground">
|
||||||
|
<span className="font-semibold">Collaborative heatmap</span>
|
||||||
|
<Badge variant="destructive">LIVE</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-[0.8rem] leading-relaxed text-muted-foreground">
|
||||||
|
Click anywhere to drop a signal. We blend every report into a shared danger zone heatmap focused on your
|
||||||
|
surroundings.
|
||||||
|
</p>
|
||||||
|
<Separator className="bg-border/40" />
|
||||||
|
<div className="flex flex-col gap-1 text-[0.75rem] text-muted-foreground">
|
||||||
|
<span>Local signals: {localTotals.points} • Last sync {lastUpdatedLabel}</span>
|
||||||
|
<span>{locationHint}</span>
|
||||||
|
</div>
|
||||||
|
{showLocationCta && (
|
||||||
|
<div className="pointer-events-auto flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startLocationWatch()}
|
||||||
|
disabled={isRequestingLocation}
|
||||||
|
>
|
||||||
|
{isRequestingLocation ? 'Requesting location…' : 'Enable location'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary/15 text-primary',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||||
|
destructive: 'border-transparent bg-destructive/15 text-destructive',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
success: 'border-transparent bg-emerald-500/15 text-emerald-300',
|
||||||
|
muted: 'border-transparent bg-muted/60 text-muted-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||||
|
({ className, variant, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Badge.displayName = 'Badge'
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 focus-visible:ring-offset-background',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground shadow-sm',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-5 text-base',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
return (
|
||||||
|
<Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export { buttonVariants }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@lib/utils'
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-border/60 bg-card/80 text-card-foreground shadow-xl shadow-black/30 backdrop-blur',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardHeader.displayName = 'CardHeader'
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3 ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardTitle.displayName = 'CardTitle'
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardDescription.displayName = 'CardDescription'
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardContent.displayName = 'CardContent'
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center p-6 pt-0 text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardFooter.displayName = 'CardFooter'
|
||||||
|
|
||||||
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@lib/utils'
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & { orientation?: 'horizontal' | 'vertical' }
|
||||||
|
>(({ className, orientation = 'horizontal', ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border/60',
|
||||||
|
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
role="none"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Separator.displayName = 'Separator'
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
+77
-60
@@ -1,68 +1,85 @@
|
|||||||
:root {
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
@tailwind base;
|
||||||
color: rgba(255, 255, 255, 0.87);
|
@tailwind components;
|
||||||
background-color: #242424;
|
@tailwind utilities;
|
||||||
|
|
||||||
font-synthesis: none;
|
@layer base {
|
||||||
text-rendering: optimizeLegibility;
|
html,
|
||||||
-webkit-font-smoothing: antialiased;
|
body,
|
||||||
-moz-osx-font-smoothing: grayscale;
|
#root {
|
||||||
}
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.2em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
:root {
|
||||||
color: #213547;
|
color-scheme: dark;
|
||||||
background-color: #ffffff;
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 6.5%;
|
||||||
|
--card-foreground: 210 40% 96%;
|
||||||
|
--popover: 222.2 84% 6.5%;
|
||||||
|
--popover-foreground: 210 40% 96%;
|
||||||
|
--primary: 199 89% 62%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 96%;
|
||||||
|
--muted: 223 50% 12%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 199 89% 62%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 221.6 30% 23%;
|
||||||
|
--input: 221.6 30% 23%;
|
||||||
|
--ring: 199 89% 62%;
|
||||||
|
--radius: 0.8rem;
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
}
|
}
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
* {
|
||||||
|
@apply border-border;
|
||||||
}
|
}
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
body {
|
||||||
|
@apply min-h-screen bg-background bg-radial-signal text-foreground antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
@apply leading-relaxed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-tooltip {
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 15px 35px rgba(2, 6, 23, 0.45);
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-tooltip-top:before,
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
border-top-color: rgba(15, 23, 42, 0.9);
|
||||||
|
border-bottom-color: rgba(15, 23, 42, 0.9);
|
||||||
|
border-left-color: rgba(15, 23, 42, 0.9);
|
||||||
|
border-right-color: rgba(15, 23, 42, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.status-dot::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -0.35rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
opacity: 0.35;
|
||||||
|
animation: status-pulse 2.4s ease-out infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export type Coordinates = {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCoordinate(value: number): string {
|
||||||
|
const formatter = new Intl.NumberFormat('en-US', {
|
||||||
|
minimumFractionDigits: 3,
|
||||||
|
maximumFractionDigits: 3,
|
||||||
|
})
|
||||||
|
return formatter.format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelativeTime(dateIso: string): string {
|
||||||
|
const date = new Date(dateIso)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = Math.max(0, now.getTime() - date.getTime())
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000)
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${seconds}s ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes}m ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) {
|
||||||
|
return `${hours}h ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) {
|
||||||
|
return `${days}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeks = Math.floor(days / 7)
|
||||||
|
return `${weeks}w ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimestamp(dateIso: string): string {
|
||||||
|
const date = new Date(dateIso)
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EARTH_RADIUS_KM = 6371
|
||||||
|
|
||||||
|
export function distanceInKm(a: Coordinates, b: Coordinates): number {
|
||||||
|
const lat1 = toRadians(a.lat)
|
||||||
|
const lat2 = toRadians(b.lat)
|
||||||
|
const deltaLat = toRadians(b.lat - a.lat)
|
||||||
|
const deltaLng = toRadians(b.lng - a.lng)
|
||||||
|
|
||||||
|
const haversine =
|
||||||
|
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
|
||||||
|
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLng / 2) * Math.sin(deltaLng / 2)
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
|
||||||
|
return EARTH_RADIUS_KM * c
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRadians(value: number): number {
|
||||||
|
return (value * Math.PI) / 180
|
||||||
|
}
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import '@/index.css'
|
||||||
import App from './App.tsx'
|
import App from '@/App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
backgroundImage: {
|
||||||
|
'radial-signal': 'radial-gradient(circle at top, rgba(56,189,248,0.15), transparent 45%), radial-gradient(circle at bottom, rgba(248,113,113,0.12), transparent 55%)',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'status-pulse': {
|
||||||
|
'0%': { transform: 'scale(0.7)', opacity: '0.6' },
|
||||||
|
'70%': { transform: 'scale(1.4)', opacity: '0' },
|
||||||
|
'100%': { transform: 'scale(0.7)', opacity: '0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'status-pulse': 'status-pulse 2.4s ease-out infinite',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('tailwindcss-animate')],
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"@ui/*": ["src/components/ui/*"],
|
||||||
|
"@lib/*": ["src/lib/*"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -10,4 +11,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
'@ui': fileURLToPath(new URL('./src/components/ui', import.meta.url)),
|
||||||
|
'@lib': fileURLToPath(new URL('./src/lib', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+11
@@ -24,3 +24,14 @@ APP_SECRET=
|
|||||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||||
DEFAULT_URI=http://localhost
|
DEFAULT_URI=http://localhost
|
||||||
###< symfony/routing ###
|
###< symfony/routing ###
|
||||||
|
|
||||||
|
###> doctrine/doctrine-bundle ###
|
||||||
|
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
|
||||||
|
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
|
||||||
|
#
|
||||||
|
DATABASE_URL="sqlite:///%kernel.project_dir%/var/points.sqlite"
|
||||||
|
###< doctrine/doctrine-bundle ###
|
||||||
|
|
||||||
|
###> nelmio/cors-bundle ###
|
||||||
|
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||||
|
###< nelmio/cors-bundle ###
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# define your env variables for the test env here
|
||||||
|
KERNEL_CLASS='App\Kernel'
|
||||||
|
APP_SECRET='$ecretf0rt3st'
|
||||||
|
CORS_ALLOW_ORIGIN='^https?://(?:localhost|127\\.0\\.0\\.1)(?::[0-9]+)?$'
|
||||||
@@ -8,3 +8,12 @@
|
|||||||
/var/
|
/var/
|
||||||
/vendor/
|
/vendor/
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
|
###> phpunit/phpunit ###
|
||||||
|
/phpunit.xml
|
||||||
|
/.phpunit.cache/
|
||||||
|
###< phpunit/phpunit ###
|
||||||
|
|
||||||
|
###> phpstan/phpstan ###
|
||||||
|
phpstan.neon
|
||||||
|
###< phpstan/phpstan ###
|
||||||
|
|||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
if (!ini_get('date.timezone')) {
|
||||||
|
ini_set('date.timezone', 'UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
|
||||||
|
if (PHP_VERSION_ID >= 80000) {
|
||||||
|
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||||
|
} else {
|
||||||
|
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php');
|
||||||
|
require PHPUNIT_COMPOSER_INSTALL;
|
||||||
|
PHPUnit\TextUI\Command::main();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
|
||||||
|
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
|
||||||
|
}
|
||||||
+17
-2
@@ -7,15 +7,22 @@
|
|||||||
"php": ">=8.2",
|
"php": ">=8.2",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
|
"doctrine/dbal": "^3",
|
||||||
|
"doctrine/doctrine-bundle": "^2.17",
|
||||||
|
"doctrine/doctrine-fixtures-bundle": "^4.1",
|
||||||
|
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||||
|
"doctrine/orm": "^3.5",
|
||||||
|
"nelmio/cors-bundle": "^2.5",
|
||||||
"symfony/console": "7.3.*",
|
"symfony/console": "7.3.*",
|
||||||
"symfony/dotenv": "7.3.*",
|
"symfony/dotenv": "7.3.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "7.3.*",
|
"symfony/framework-bundle": "7.3.*",
|
||||||
|
"symfony/property-access": "7.3.*",
|
||||||
|
"symfony/property-info": "7.3.*",
|
||||||
"symfony/runtime": "7.3.*",
|
"symfony/runtime": "7.3.*",
|
||||||
|
"symfony/serializer": "^7.3",
|
||||||
"symfony/yaml": "7.3.*"
|
"symfony/yaml": "7.3.*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
|
||||||
},
|
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"php-http/discovery": true,
|
"php-http/discovery": true,
|
||||||
@@ -65,5 +72,13 @@
|
|||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "7.3.*"
|
"require": "7.3.*"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan-symfony": "^2.0",
|
||||||
|
"phpunit/phpunit": "^12.4",
|
||||||
|
"rector/rector": "^2.2",
|
||||||
|
"symfony/browser-kit": "7.3.*",
|
||||||
|
"symfony/css-selector": "7.3.*",
|
||||||
|
"symplify/easy-coding-standard": "^12.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+4164
-4
File diff suppressed because it is too large
Load Diff
@@ -2,4 +2,8 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||||
|
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||||
|
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||||
|
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||||
|
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
|
|
||||||
|
# IMPORTANT: You MUST configure your server version,
|
||||||
|
# either here or in the DATABASE_URL env var (see .env file)
|
||||||
|
#server_version: '16'
|
||||||
|
|
||||||
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
|
use_savepoints: true
|
||||||
|
orm:
|
||||||
|
auto_generate_proxy_classes: true
|
||||||
|
enable_lazy_ghost_objects: true
|
||||||
|
report_fields_where_declared: true
|
||||||
|
validate_xml_mapping: true
|
||||||
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
|
identity_generation_preferences:
|
||||||
|
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
||||||
|
auto_mapping: true
|
||||||
|
mappings:
|
||||||
|
App:
|
||||||
|
type: attribute
|
||||||
|
is_bundle: false
|
||||||
|
dir: '%kernel.project_dir%/src/Entity'
|
||||||
|
prefix: 'App\Entity'
|
||||||
|
alias: App
|
||||||
|
controller_resolver:
|
||||||
|
auto_mapping: false
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
# "TEST_TOKEN" is typically set by ParaTest
|
||||||
|
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
doctrine:
|
||||||
|
orm:
|
||||||
|
auto_generate_proxy_classes: false
|
||||||
|
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
||||||
|
query_cache_driver:
|
||||||
|
type: pool
|
||||||
|
pool: doctrine.system_cache_pool
|
||||||
|
result_cache_driver:
|
||||||
|
type: pool
|
||||||
|
pool: doctrine.result_cache_pool
|
||||||
|
|
||||||
|
framework:
|
||||||
|
cache:
|
||||||
|
pools:
|
||||||
|
doctrine.result_cache_pool:
|
||||||
|
adapter: cache.app
|
||||||
|
doctrine.system_cache_pool:
|
||||||
|
adapter: cache.system
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
doctrine_migrations:
|
||||||
|
migrations_paths:
|
||||||
|
# namespace is arbitrary but should be different from App\Migrations
|
||||||
|
# as migrations classes should NOT be autoloaded
|
||||||
|
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||||
|
enable_profiler: false
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
nelmio_cors:
|
||||||
|
defaults:
|
||||||
|
origin_regex: true
|
||||||
|
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||||
|
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||||
|
allow_headers: ['Content-Type', 'Authorization']
|
||||||
|
expose_headers: ['Link']
|
||||||
|
max_age: 3600
|
||||||
|
paths:
|
||||||
|
'^/': null
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
framework:
|
||||||
|
property_info:
|
||||||
|
with_constructor_extractor: true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
imports:
|
||||||
|
- { resource: ../doctrine.yaml }
|
||||||
|
|
||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
url: 'sqlite:///%kernel.project_dir%/var/test.sqlite'
|
||||||
|
use_savepoints: true
|
||||||
@@ -15,6 +15,11 @@ services:
|
|||||||
# this creates a service per class whose id is the fully-qualified class name
|
# this creates a service per class whose id is the fully-qualified class name
|
||||||
App\:
|
App\:
|
||||||
resource: '../src/'
|
resource: '../src/'
|
||||||
|
exclude: '../src/Kernel.php'
|
||||||
|
|
||||||
|
App\Controller\:
|
||||||
|
resource: '../src/Controller/'
|
||||||
|
tags: ['controller.service_arguments']
|
||||||
|
|
||||||
# add more service definitions when explicit configuration is needed
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# please note that last definitions always *replace* previous ones
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PhpCsFixer\Fixer\FunctionNotation\MethodArgumentSpaceFixer;
|
||||||
|
use PhpCsFixer\Fixer\Import\NoUnusedImportsFixer;
|
||||||
|
use PhpCsFixer\Fixer\Operator\ConcatSpaceFixer;
|
||||||
|
use Symplify\EasyCodingStandard\Config\ECSConfig;
|
||||||
|
|
||||||
|
return ECSConfig::configure()
|
||||||
|
->withPaths([
|
||||||
|
__DIR__ . '/src',
|
||||||
|
__DIR__ . '/tests',
|
||||||
|
])
|
||||||
|
->withRules([
|
||||||
|
NoUnusedImportsFixer::class,
|
||||||
|
])
|
||||||
|
->withConfiguredRule(MethodArgumentSpaceFixer::class, [
|
||||||
|
'on_multiline' => 'ensure_fully_multiline',
|
||||||
|
'attribute_placement' => 'same_line'
|
||||||
|
])
|
||||||
|
->withSkip([
|
||||||
|
ConcatSpaceFixer::class
|
||||||
|
])
|
||||||
|
->withPreparedSets(
|
||||||
|
psr12: true,
|
||||||
|
common: true,
|
||||||
|
cleanCode: true,
|
||||||
|
);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20240919000000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create signals table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE signals (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_key VARCHAR(64) NOT NULL, lat DOUBLE PRECISION NOT NULL, lng DOUBLE PRECISION NOT NULL, created_at DATETIME NOT NULL)');
|
||||||
|
$this->addSql('CREATE INDEX idx_signals_created_at ON signals (created_at)');
|
||||||
|
$this->addSql('CREATE INDEX idx_signals_user_key ON signals (user_key)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE signals');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
includes:
|
||||||
|
- vendor/phpstan/phpstan-symfony/extension.neon
|
||||||
|
- vendor/phpstan/phpstan-symfony/rules.neon
|
||||||
|
# - vendor/phpstan/phpstan-doctrine/extension.neon
|
||||||
|
# - vendor/phpstan/phpstan-doctrine/rules.neon
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
level: 8
|
||||||
|
paths:
|
||||||
|
- bin/
|
||||||
|
- config/
|
||||||
|
- public/
|
||||||
|
- src/
|
||||||
|
- tests/
|
||||||
|
reportUnmatchedIgnoredErrors: false
|
||||||
|
ignoreErrors:
|
||||||
|
- identifier: missingType.iterableValue
|
||||||
|
# doctrine:
|
||||||
|
# objectManagerLoader: tests/object-manager.php
|
||||||
|
# allowNullablePropertyForRequiredField: true
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
colors="true"
|
||||||
|
failOnDeprecation="true"
|
||||||
|
failOnNotice="true"
|
||||||
|
failOnWarning="true"
|
||||||
|
bootstrap="tests/bootstrap.php"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
|
>
|
||||||
|
<php>
|
||||||
|
<ini name="display_errors" value="1" />
|
||||||
|
<ini name="error_reporting" value="-1" />
|
||||||
|
<server name="APP_ENV" value="test" force="true" />
|
||||||
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
|
</php>
|
||||||
|
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Project Test Suite">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
|
||||||
|
<source ignoreSuppressionOfDeprecations="true"
|
||||||
|
ignoreIndirectDeprecations="true"
|
||||||
|
restrictNotices="true"
|
||||||
|
restrictWarnings="true"
|
||||||
|
>
|
||||||
|
<include>
|
||||||
|
<directory>src</directory>
|
||||||
|
</include>
|
||||||
|
|
||||||
|
<deprecationTrigger>
|
||||||
|
<method>Doctrine\Deprecations\Deprecation::trigger</method>
|
||||||
|
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
|
||||||
|
<function>trigger_deprecation</function>
|
||||||
|
</deprecationTrigger>
|
||||||
|
</source>
|
||||||
|
|
||||||
|
<extensions>
|
||||||
|
</extensions>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Rector\CodingStyle\Rector\Catch_\CatchExceptionNameMatchingTypeRector;
|
||||||
|
use Rector\Config\RectorConfig;
|
||||||
|
use Rector\Exception\Configuration\InvalidConfigurationException;
|
||||||
|
use Rector\ValueObject\PhpVersion;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return RectorConfig::configure()
|
||||||
|
->withPaths([
|
||||||
|
__DIR__ . '/src',
|
||||||
|
__DIR__ . '/tests',
|
||||||
|
])
|
||||||
|
->withImportNames(
|
||||||
|
importDocBlockNames: false,
|
||||||
|
importShortClasses: false,
|
||||||
|
removeUnusedImports: true
|
||||||
|
)
|
||||||
|
->withPhpVersion(PhpVersion::PHP_84)
|
||||||
|
->withPhpSets(php84: true)
|
||||||
|
->withPreparedSets(
|
||||||
|
deadCode: true,
|
||||||
|
codeQuality: true,
|
||||||
|
codingStyle: true,
|
||||||
|
typeDeclarations: true,
|
||||||
|
privatization: true,
|
||||||
|
instanceOf: true,
|
||||||
|
earlyReturn: true,
|
||||||
|
doctrineCodeQuality: true
|
||||||
|
)
|
||||||
|
->withSkip([
|
||||||
|
CatchExceptionNameMatchingTypeRector::class
|
||||||
|
]);
|
||||||
|
} catch (InvalidConfigurationException $e) {
|
||||||
|
echo $e->getMessage();
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Dto\SignalPayload;
|
||||||
|
use App\Entity\Signal;
|
||||||
|
use App\Repository\SignalRepository;
|
||||||
|
use App\Service\SignalSnapshotBuilder;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
|
||||||
|
#[Route(path: '/api/signals')]
|
||||||
|
class SignalController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SignalRepository $signals,
|
||||||
|
private readonly SignalSnapshotBuilder $snapshotBuilder,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '', name: 'api_signals_index', methods: ['GET'])]
|
||||||
|
public function index(Request $request, #[MapQueryParameter('limit', flags: FILTER_NULL_ON_FAILURE)] ?int $limit = null): JsonResponse
|
||||||
|
{
|
||||||
|
$limit = $this->normalizeLimit($limit);
|
||||||
|
|
||||||
|
$clientKey = $this->hashIp($this->extractClientIp($request));
|
||||||
|
|
||||||
|
$signals = $this->signals->findRecent($limit);
|
||||||
|
$snapshot = $this->snapshotBuilder->build($signals);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'clientKey' => $clientKey,
|
||||||
|
'points' => $snapshot['points'],
|
||||||
|
'density' => $snapshot['density'],
|
||||||
|
'latestByUser' => $snapshot['latestByUser'],
|
||||||
|
'totals' => $snapshot['totals'],
|
||||||
|
'updatedAt' => new DateTimeImmutable('now', new DateTimeZone('UTC'))->format(DATE_ATOM),
|
||||||
|
];
|
||||||
|
|
||||||
|
return new JsonResponse($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '', name: 'api_signals_store', methods: ['POST'])]
|
||||||
|
public function store(#[MapRequestPayload(validationFailedStatusCode: JsonResponse::HTTP_UNPROCESSABLE_ENTITY)] SignalPayload $payload, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$lat = $payload->lat;
|
||||||
|
$lng = $payload->lng;
|
||||||
|
|
||||||
|
if (! is_finite($lat) || ! is_finite($lng)) {
|
||||||
|
return $this->errorResponse('invalid_coordinates', 'Latitude and longitude must be numbers.', JsonResponse::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lat < -90 || $lat > 90 || $lng < -180 || $lng > 180) {
|
||||||
|
return $this->errorResponse('out_of_bounds', 'Latitude or longitude out of range.', JsonResponse::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientIp = $this->extractClientIp($request);
|
||||||
|
$clientKey = $this->hashIp($clientIp);
|
||||||
|
|
||||||
|
$signal = new Signal()
|
||||||
|
->setUserKey($clientKey)
|
||||||
|
->setLat($lat)
|
||||||
|
->setLng($lng)
|
||||||
|
->setCreatedAt(new DateTimeImmutable('now', new DateTimeZone('UTC')));
|
||||||
|
|
||||||
|
$this->signals->save($signal);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'status' => 'stored',
|
||||||
|
'clientKey' => $clientKey,
|
||||||
|
'point' => [
|
||||||
|
'id' => $signal->getId(),
|
||||||
|
'lat' => $signal->getLat(),
|
||||||
|
'lng' => $signal->getLng(),
|
||||||
|
'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM),
|
||||||
|
'userKey' => $signal->getUserKey(),
|
||||||
|
],
|
||||||
|
], JsonResponse::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function errorResponse(string $error, string $message, int $statusCode): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse([
|
||||||
|
'error' => $error,
|
||||||
|
'message' => $message,
|
||||||
|
], $statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeLimit(?int $limit): int
|
||||||
|
{
|
||||||
|
$limit ??= 750;
|
||||||
|
if ($limit < 1 || $limit > 5000) {
|
||||||
|
return 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractClientIp(Request $request): string
|
||||||
|
{
|
||||||
|
$forwarded = $request->headers->get('X-Forwarded-For');
|
||||||
|
if ($forwarded !== null && $forwarded !== '') {
|
||||||
|
$parts = array_filter(array_map('trim', explode(',', $forwarded)));
|
||||||
|
if ($parts !== []) {
|
||||||
|
return $parts[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $request->getClientIp() ?? '0.0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hashIp(string $ip): string
|
||||||
|
{
|
||||||
|
return substr(hash('sha256', $ip), 0, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
|
||||||
|
class AppFixtures extends Fixture
|
||||||
|
{
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
// $product = new Product();
|
||||||
|
// $manager->persist($product);
|
||||||
|
|
||||||
|
$manager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
|
use App\Entity\Signal;
|
||||||
|
use DateInterval;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
|
||||||
|
class SignalFixtures extends Fixture
|
||||||
|
{
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
$baseTime = new DateTimeImmutable('2024-08-01 12:00:00', new DateTimeZone('UTC'));
|
||||||
|
$coordinates = [
|
||||||
|
[
|
||||||
|
'user' => 'user-alpha',
|
||||||
|
'lat' => -11.6877,
|
||||||
|
'lng' => 27.5026,
|
||||||
|
'offset' => 0,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'user' => 'user-beta',
|
||||||
|
'lat' => -11.6895,
|
||||||
|
'lng' => 27.5081,
|
||||||
|
'offset' => 3,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'user' => 'user-alpha',
|
||||||
|
'lat' => -11.6852,
|
||||||
|
'lng' => 27.4974,
|
||||||
|
'offset' => 6,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($coordinates as $config) {
|
||||||
|
$signal = new Signal()
|
||||||
|
->setUserKey($config['user'])
|
||||||
|
->setLat($config['lat'])
|
||||||
|
->setLng($config['lng'])
|
||||||
|
->setCreatedAt($baseTime->add(new DateInterval(sprintf('PT%dM', $config['offset']))));
|
||||||
|
|
||||||
|
$manager->persist($signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
$manager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto;
|
||||||
|
|
||||||
|
final readonly class SignalPayload
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public float $lat,
|
||||||
|
public float $lng,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\SignalRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: SignalRepository::class)]
|
||||||
|
#[ORM\Table(name: 'signals')]
|
||||||
|
#[ORM\Index(columns: ['created_at'], name: 'idx_signals_created_at')]
|
||||||
|
#[ORM\Index(columns: ['user_key'], name: 'idx_signals_user_key')]
|
||||||
|
class Signal
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: Types::INTEGER)]
|
||||||
|
private ?int $id = null; // @phpstan-ignore property.unusedType
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::STRING, length: 64)]
|
||||||
|
private string $userKey;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::FLOAT)]
|
||||||
|
private float $lat;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::FLOAT)]
|
||||||
|
private float $lng;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'created_at')]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserKey(): string
|
||||||
|
{
|
||||||
|
return $this->userKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUserKey(string $userKey): self
|
||||||
|
{
|
||||||
|
$this->userKey = $userKey;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLat(): float
|
||||||
|
{
|
||||||
|
return $this->lat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLat(float $lat): self
|
||||||
|
{
|
||||||
|
$this->lat = $lat;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLng(): float
|
||||||
|
{
|
||||||
|
return $this->lng;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLng(float $lng): self
|
||||||
|
{
|
||||||
|
$this->lng = $lng;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedAt(DateTimeImmutable $createdAt): self
|
||||||
|
{
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Signal;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Signal>
|
||||||
|
*/
|
||||||
|
class SignalRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Signal::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<Signal>
|
||||||
|
*/
|
||||||
|
public function findRecent(int $limit): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('signal')
|
||||||
|
->orderBy('signal.createdAt', 'DESC')
|
||||||
|
->setMaxResults($limit)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Signal $signal): void
|
||||||
|
{
|
||||||
|
$entityManager = $this->getEntityManager();
|
||||||
|
$entityManager->persist($signal);
|
||||||
|
$entityManager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Signal;
|
||||||
|
|
||||||
|
class SignalSnapshotBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<Signal> $signals
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* points: list<array{id: int, lat: float, lng: float, createdAt: string, userKey: string}>,
|
||||||
|
* density: list<array{lat: float, lng: float, intensity: int}>,
|
||||||
|
* latestByUser: list<array{id: int, lat: float, lng: float, createdAt: string, userKey: string}>,
|
||||||
|
* totals: array{points: int, contributors: int}
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function build(array $signals): array
|
||||||
|
{
|
||||||
|
$points = [];
|
||||||
|
$densityBuckets = [];
|
||||||
|
$latestByUser = [];
|
||||||
|
|
||||||
|
foreach ($signals as $signal) {
|
||||||
|
$point = [
|
||||||
|
'id' => (int) $signal->getId(),
|
||||||
|
'lat' => $signal->getLat(),
|
||||||
|
'lng' => $signal->getLng(),
|
||||||
|
'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM),
|
||||||
|
'userKey' => $signal->getUserKey(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$points[] = $point;
|
||||||
|
|
||||||
|
$bucketLat = round($signal->getLat(), 3);
|
||||||
|
$bucketLng = round($signal->getLng(), 3);
|
||||||
|
$bucketKey = $bucketLat . ':' . $bucketLng;
|
||||||
|
|
||||||
|
if (! isset($densityBuckets[$bucketKey])) {
|
||||||
|
$densityBuckets[$bucketKey] = [
|
||||||
|
'lat' => $bucketLat,
|
||||||
|
'lng' => $bucketLng,
|
||||||
|
'intensity' => 0,
|
||||||
|
'latestPoint' => $point,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$densityBuckets[$bucketKey]['intensity']++;
|
||||||
|
if ($point['createdAt'] > $densityBuckets[$bucketKey]['latestPoint']['createdAt']) {
|
||||||
|
$densityBuckets[$bucketKey]['latestPoint'] = $point;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingLatest = $latestByUser[$point['userKey']] ?? null;
|
||||||
|
if ($existingLatest === null || $point['createdAt'] > $existingLatest['createdAt']) {
|
||||||
|
$latestByUser[$point['userKey']] = $point;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($points, static fn (array $a, array $b): int => strcmp($b['createdAt'], $a['createdAt']));
|
||||||
|
|
||||||
|
$density = array_values(array_map(
|
||||||
|
static fn (array $bucket): array => [
|
||||||
|
'lat' => $bucket['lat'],
|
||||||
|
'lng' => $bucket['lng'],
|
||||||
|
'intensity' => $bucket['intensity'],
|
||||||
|
],
|
||||||
|
$densityBuckets,
|
||||||
|
));
|
||||||
|
|
||||||
|
usort($density, static fn (array $a, array $b): int => $b['intensity'] <=> $a['intensity']);
|
||||||
|
|
||||||
|
$latest = array_values($latestByUser);
|
||||||
|
usort($latest, static fn (array $a, array $b): int => strcmp($b['createdAt'], $a['createdAt']));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'points' => $points,
|
||||||
|
'density' => $density,
|
||||||
|
'latestByUser' => $latest,
|
||||||
|
'totals' => [
|
||||||
|
'points' => count($points),
|
||||||
|
'contributors' => count($latestByUser),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,91 @@
|
|||||||
{
|
{
|
||||||
|
"doctrine/deprecations": {
|
||||||
|
"version": "1.1",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "1.0",
|
||||||
|
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"doctrine/doctrine-bundle": {
|
||||||
|
"version": "2.17",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "2.13",
|
||||||
|
"ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/doctrine.yaml",
|
||||||
|
"src/Entity/.gitignore",
|
||||||
|
"src/Repository/.gitignore"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"doctrine/doctrine-fixtures-bundle": {
|
||||||
|
"version": "4.1",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "3.0",
|
||||||
|
"ref": "1f5514cfa15b947298df4d771e694e578d4c204d"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/DataFixtures/AppFixtures.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"doctrine/doctrine-migrations-bundle": {
|
||||||
|
"version": "3.4",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "3.1",
|
||||||
|
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/doctrine_migrations.yaml",
|
||||||
|
"migrations/.gitignore"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nelmio/cors-bundle": {
|
||||||
|
"version": "2.5",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "1.5",
|
||||||
|
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/nelmio_cors.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"phpstan/phpstan": {
|
||||||
|
"version": "2.1",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes-contrib",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "1.0",
|
||||||
|
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"phpstan.dist.neon"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"phpunit/phpunit": {
|
||||||
|
"version": "12.4",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "11.1",
|
||||||
|
"ref": "c6658a60fc9d594805370eacdf542c3d6b5c0869"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".env.test",
|
||||||
|
"phpunit.dist.xml",
|
||||||
|
"tests/bootstrap.php",
|
||||||
|
"bin/phpunit"
|
||||||
|
]
|
||||||
|
},
|
||||||
"symfony/console": {
|
"symfony/console": {
|
||||||
"version": "7.3",
|
"version": "7.3",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
@@ -44,6 +131,18 @@
|
|||||||
".editorconfig"
|
".editorconfig"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"symfony/property-info": {
|
||||||
|
"version": "7.3",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "7.3",
|
||||||
|
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/property_info.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
"symfony/routing": {
|
"symfony/routing": {
|
||||||
"version": "7.3",
|
"version": "7.3",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional;
|
||||||
|
|
||||||
|
use App\DataFixtures\SignalFixtures;
|
||||||
|
use App\Entity\Signal;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\Tools\SchemaTool;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SignalControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private KernelBrowser $client;
|
||||||
|
|
||||||
|
private EntityManagerInterface $entityManager;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->client = static::createClient();
|
||||||
|
$entityManager = $this->client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
self::assertInstanceOf(EntityManagerInterface::class, $entityManager);
|
||||||
|
$this->entityManager = $entityManager;
|
||||||
|
|
||||||
|
$metadata = $this->entityManager->getMetadataFactory()->getAllMetadata();
|
||||||
|
$schemaTool = new SchemaTool($this->entityManager);
|
||||||
|
$schemaTool->dropDatabase();
|
||||||
|
if ($metadata !== []) {
|
||||||
|
$schemaTool->createSchema($metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
new SignalFixtures()->load($this->entityManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
parent::tearDown();
|
||||||
|
|
||||||
|
if (isset($this->entityManager)) {
|
||||||
|
$this->entityManager->close();
|
||||||
|
unset($this->entityManager);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexReturnsRecentSignals(): void
|
||||||
|
{
|
||||||
|
$this->client->request('GET', '/api/signals');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$content = $this->client->getResponse()->getContent();
|
||||||
|
self::assertIsString($content);
|
||||||
|
$payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
self::assertSame(substr(hash('sha256', '127.0.0.1'), 0, 12), $payload['clientKey']);
|
||||||
|
self::assertCount(3, $payload['points']);
|
||||||
|
self::assertSame(3, $payload['totals']['points']);
|
||||||
|
self::assertSame(2, $payload['totals']['contributors']);
|
||||||
|
self::assertSame(-11.6852, $payload['points'][0]['lat']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexRespectsLimitQueryParameter(): void
|
||||||
|
{
|
||||||
|
$this->client->request('GET', '/api/signals?limit=1');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$content = $this->client->getResponse()->getContent();
|
||||||
|
self::assertIsString($content);
|
||||||
|
$payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
self::assertCount(1, $payload['points']);
|
||||||
|
self::assertSame(1, $payload['totals']['points']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStorePersistsNewSignal(): void
|
||||||
|
{
|
||||||
|
$body = [
|
||||||
|
'lat' => -11.6901,
|
||||||
|
'lng' => 27.4959,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->client->request('POST', '/api/signals', [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], json_encode($body, JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_CREATED);
|
||||||
|
|
||||||
|
$content = $this->client->getResponse()->getContent();
|
||||||
|
self::assertIsString($content);
|
||||||
|
$payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertSame('stored', $payload['status']);
|
||||||
|
self::assertSame($body['lat'], $payload['point']['lat']);
|
||||||
|
self::assertSame($body['lng'], $payload['point']['lng']);
|
||||||
|
|
||||||
|
$repository = $this->entityManager->getRepository(Signal::class);
|
||||||
|
$signals = $repository->findAll();
|
||||||
|
self::assertCount(4, $signals);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStoreRejectsInvalidCoordinates(): void
|
||||||
|
{
|
||||||
|
$body = [
|
||||||
|
'lat' => 181,
|
||||||
|
'lng' => 10,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->client->request('POST', '/api/signals', [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], json_encode($body, JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
|
$content = $this->client->getResponse()->getContent();
|
||||||
|
self::assertIsString($content);
|
||||||
|
$payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertSame('out_of_bounds', $payload['error']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Symfony\Component\Dotenv\Dotenv;
|
||||||
|
|
||||||
|
require dirname(__DIR__).'/vendor/autoload.php';
|
||||||
|
|
||||||
|
new Dotenv()->bootEnv(dirname(__DIR__).'/.env');
|
||||||
|
|
||||||
|
if ($_SERVER['APP_DEBUG']) {
|
||||||
|
umask(0000);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user