Phase 1 : seed CoastalPoints + frontend React complet
Backend : - SeedCoastalPointsCommand : 29 points Antilles (idempotent) Martinique, Guadeloupe, Sainte-Lucie, Barbade, Saint-Martin Frontend : - api/client.js : wrapper fetch pour spots, observations, feedback - SargassesMap : MapLibre GL JS, marqueurs spots, couche observations GeoJSON avec rechargement bbox dynamique à chaque mouvement carte - SpotPanel : Spot Mode (score/niveau/distance/trend), bouton feedback terrain anonyme, gestion état loading/error/null - App.jsx : composition map + panel, état selectedSpot - vite.config.js : proxy /api → Symfony dev, build → backend/public/spa Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,184 +1,14 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #0f172a;
|
||||
color: #f1f5f9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero {
|
||||
.app {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
@@ -1,121 +1,21 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import './App.css'
|
||||
import { useState } from 'react';
|
||||
import SargassesMap from './components/Map/SargassesMap';
|
||||
import SpotPanel from './components/SpotPanel/SpotPanel';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
export default function App() {
|
||||
const [selectedSpot, setSelectedSpot] = useState(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.jsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</>
|
||||
)
|
||||
<div className="app">
|
||||
<SargassesMap
|
||||
onSpotSelect={setSelectedSpot}
|
||||
selectedSpotId={selectedSpot?.id}
|
||||
/>
|
||||
<SpotPanel
|
||||
spot={selectedSpot}
|
||||
onClose={() => setSelectedSpot(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
39
frontend/src/api/client.js
Normal file
39
frontend/src/api/client.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
async function get(path, params = {}) {
|
||||
const url = new URL(BASE_URL + path, window.location.origin);
|
||||
Object.entries(params).forEach(([k, v]) => v !== undefined && url.searchParams.set(k, v));
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`API error ${res.status}: ${path}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function post(path, body) {
|
||||
const res = await fetch(BASE_URL + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`API error ${res.status}: ${path}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
spots: {
|
||||
list: () => get('/spots'),
|
||||
get: (id) => get(`/spots/${id}`),
|
||||
score: (id, at) => get(`/spots/${id}/score`, { at }),
|
||||
},
|
||||
observations: {
|
||||
list: (bbox, date, source) => get('/observations', { bbox, date, source }),
|
||||
get: (id) => get(`/observations/${id}`),
|
||||
},
|
||||
feedback: {
|
||||
create: (data) => post('/feedback', data),
|
||||
},
|
||||
};
|
||||
153
frontend/src/components/Map/SargassesMap.jsx
Normal file
153
frontend/src/components/Map/SargassesMap.jsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Map, { Layer, Marker, NavigationControl, Source } from 'react-map-gl/maplibre';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
// Emprise initiale : Antilles
|
||||
const INITIAL_VIEW = {
|
||||
longitude: -61.2,
|
||||
latitude: 15.5,
|
||||
zoom: 7,
|
||||
};
|
||||
|
||||
const LEVEL_COLOR = {
|
||||
low: '#22c55e', // vert
|
||||
medium: '#f59e0b', // orange
|
||||
high: '#ef4444', // rouge
|
||||
};
|
||||
|
||||
// Styles des couches MapLibre pour les observations
|
||||
const OBSERVATION_FILL = {
|
||||
id: 'observations-fill',
|
||||
type: 'fill',
|
||||
source: 'observations',
|
||||
paint: {
|
||||
'fill-color': '#f59e0b',
|
||||
'fill-opacity': 0.35,
|
||||
},
|
||||
};
|
||||
|
||||
const OBSERVATION_LINE = {
|
||||
id: 'observations-line',
|
||||
type: 'line',
|
||||
source: 'observations',
|
||||
paint: {
|
||||
'line-color': '#d97706',
|
||||
'line-width': 2,
|
||||
},
|
||||
};
|
||||
|
||||
export default function SargassesMap({ onSpotSelect, selectedSpotId }) {
|
||||
const mapRef = useRef(null);
|
||||
const [spots, setSpots] = useState([]);
|
||||
const [observationGeoJSON, setObs] = useState(null);
|
||||
const [viewState, setViewState] = useState(INITIAL_VIEW);
|
||||
|
||||
// Charge les spots côtiers une seule fois
|
||||
useEffect(() => {
|
||||
api.spots.list()
|
||||
.then(data => setSpots(data['hydra:member'] ?? data.member ?? []))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Charge les observations quand la carte bouge (debounced)
|
||||
const loadObservations = useCallback(() => {
|
||||
const map = mapRef.current?.getMap();
|
||||
if (!map) return;
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const bbox = [
|
||||
bounds.getWest().toFixed(4),
|
||||
bounds.getSouth().toFixed(4),
|
||||
bounds.getEast().toFixed(4),
|
||||
bounds.getNorth().toFixed(4),
|
||||
].join(',');
|
||||
|
||||
api.observations.list(bbox)
|
||||
.then(data => {
|
||||
if (!data.items?.length) { setObs(null); return; }
|
||||
|
||||
// Regroupe toutes les observations en une FeatureCollection
|
||||
const features = data.items
|
||||
.filter(o => o.geometry)
|
||||
.map(o => ({
|
||||
type: 'Feature',
|
||||
geometry: o.geometry,
|
||||
properties: {
|
||||
id: o.id,
|
||||
detectedAt: o.detectedAt,
|
||||
cloudCoverage: o.cloudCoverage,
|
||||
coverageArea: o.coverageArea,
|
||||
},
|
||||
}));
|
||||
|
||||
setObs({ type: 'FeatureCollection', features });
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Recharge les observations après chaque fin de mouvement
|
||||
const onMoveEnd = useCallback(() => {
|
||||
loadObservations();
|
||||
}, [loadObservations]);
|
||||
|
||||
// Extraction des coordonnées depuis la géométrie PostGIS (Point GeoJSON)
|
||||
const getCoords = (spot) => {
|
||||
const coords = spot.geometry?.coordinates;
|
||||
if (!coords) return null;
|
||||
return { lng: coords[0], lat: coords[1] };
|
||||
};
|
||||
|
||||
return (
|
||||
<Map
|
||||
ref={mapRef}
|
||||
{...viewState}
|
||||
onMove={e => setViewState(e.viewState)}
|
||||
onMoveEnd={onMoveEnd}
|
||||
onLoad={loadObservations}
|
||||
mapStyle="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
|
||||
style={{ width: '100%', height: '100vh' }}
|
||||
>
|
||||
<NavigationControl position="top-right" />
|
||||
|
||||
{/* Couche observations sargasses */}
|
||||
{observationGeoJSON && (
|
||||
<Source id="observations" type="geojson" data={observationGeoJSON}>
|
||||
<Layer {...OBSERVATION_FILL} />
|
||||
<Layer {...OBSERVATION_LINE} />
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* Marqueurs spots côtiers */}
|
||||
{spots.map(spot => {
|
||||
const coords = getCoords(spot);
|
||||
if (!coords) return null;
|
||||
|
||||
const isSelected = spot.id === selectedSpotId;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={spot.id}
|
||||
longitude={coords.lng}
|
||||
latitude={coords.lat}
|
||||
anchor="center"
|
||||
onClick={() => onSpotSelect(spot)}
|
||||
>
|
||||
<div
|
||||
title={spot.name}
|
||||
style={{
|
||||
width: isSelected ? 16 : 10,
|
||||
height: isSelected ? 16 : 10,
|
||||
borderRadius: '50%',
|
||||
background: isSelected ? '#38bdf8' : '#fff',
|
||||
border: `2px solid ${isSelected ? '#0ea5e9' : '#94a3b8'}`,
|
||||
cursor: 'pointer',
|
||||
transition: 'all .15s',
|
||||
}}
|
||||
/>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</Map>
|
||||
);
|
||||
}
|
||||
109
frontend/src/components/SpotPanel/SpotPanel.css
Normal file
109
frontend/src/components/SpotPanel/SpotPanel.css
Normal file
@@ -0,0 +1,109 @@
|
||||
.spot-panel {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
color: #f1f5f9;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, .5);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.spot-panel__close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.spot-panel__close:hover { background: #334155; }
|
||||
|
||||
.spot-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.spot-panel__icon { font-size: 28px; }
|
||||
.spot-panel__name { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
.spot-panel__region { margin: 2px 0 0; font-size: 13px; color: #94a3b8; }
|
||||
|
||||
/* Score */
|
||||
.spot-panel__score { margin-bottom: 16px; }
|
||||
|
||||
.spot-panel__level {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.level--ok { background: #14532d; color: #86efac; }
|
||||
.level--risk { background: #78350f; color: #fcd34d; }
|
||||
.level--impact { background: #7f1d1d; color: #fca5a5; }
|
||||
|
||||
.spot-panel__level-label { font-size: 18px; }
|
||||
.spot-panel__level-value { font-size: 14px; opacity: .8; }
|
||||
|
||||
.spot-panel__details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 12px;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
.spot-panel__details dt { color: #94a3b8; }
|
||||
.spot-panel__details dd { margin: 0; font-weight: 500; }
|
||||
|
||||
.spot-panel__loading,
|
||||
.spot-panel__no-data,
|
||||
.spot-panel__error { color: #94a3b8; font-size: 14px; text-align: center; padding: 12px 0; }
|
||||
.spot-panel__error { color: #f87171; }
|
||||
|
||||
/* Feedback */
|
||||
.spot-panel__feedback {
|
||||
border-top: 1px solid #334155;
|
||||
padding-top: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.spot-panel__feedback p { margin: 0 0 10px; font-size: 13px; color: #94a3b8; }
|
||||
|
||||
.spot-panel__feedback-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
.spot-panel__feedback-btns button {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #475569;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
color: #f1f5f9;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.spot-panel__feedback-btns button:hover:not(:disabled) { background: #1e293b; border-color: #64748b; }
|
||||
.spot-panel__feedback-btns button:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
.spot-panel__feedback-thanks {
|
||||
text-align: center;
|
||||
color: #86efac;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
118
frontend/src/components/SpotPanel/SpotPanel.jsx
Normal file
118
frontend/src/components/SpotPanel/SpotPanel.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import './SpotPanel.css';
|
||||
|
||||
const LEVEL_LABEL = { low: 'OK', medium: 'Risque', high: 'Impact' };
|
||||
const LEVEL_CLASS = { low: 'level--ok', medium: 'level--risk', high: 'level--impact' };
|
||||
const TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' };
|
||||
|
||||
export default function SpotPanel({ spot, onClose }) {
|
||||
const [score, setScore] = useState(undefined); // undefined = loading
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!spot) return;
|
||||
setScore(undefined);
|
||||
setError(null);
|
||||
|
||||
api.spots.score(spot.id)
|
||||
.then(data => setScore(data))
|
||||
.catch(() => setError('Impossible de charger le score.'));
|
||||
}, [spot?.id]);
|
||||
|
||||
if (!spot) return null;
|
||||
|
||||
const icon = TYPE_ICON[spot.type] ?? '📍';
|
||||
const lvl = score?.score?.level;
|
||||
|
||||
return (
|
||||
<div className="spot-panel">
|
||||
<button className="spot-panel__close" onClick={onClose} aria-label="Fermer">✕</button>
|
||||
|
||||
<div className="spot-panel__header">
|
||||
<span className="spot-panel__icon">{icon}</span>
|
||||
<div>
|
||||
<h2 className="spot-panel__name">{spot.name}</h2>
|
||||
<p className="spot-panel__region">{spot.region}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="spot-panel__score">
|
||||
{score === undefined && <p className="spot-panel__loading">Chargement…</p>}
|
||||
|
||||
{error && <p className="spot-panel__error">{error}</p>}
|
||||
|
||||
{score !== undefined && !error && (
|
||||
score.score === null
|
||||
? <p className="spot-panel__no-data">Aucune donnée disponible pour ce spot.</p>
|
||||
: (
|
||||
<>
|
||||
<div className={`spot-panel__level ${LEVEL_CLASS[lvl] ?? ''}`}>
|
||||
<span className="spot-panel__level-label">{LEVEL_LABEL[lvl] ?? lvl}</span>
|
||||
<span className="spot-panel__level-value">{score.score.value} / 100</span>
|
||||
</div>
|
||||
|
||||
<dl className="spot-panel__details">
|
||||
{score.score.distance != null && (
|
||||
<>
|
||||
<dt>Distance sargasses</dt>
|
||||
<dd>{score.score.distance.toFixed(1)} km</dd>
|
||||
</>
|
||||
)}
|
||||
{score.score.trend && (
|
||||
<>
|
||||
<dt>Tendance</dt>
|
||||
<dd>{score.score.trend}</dd>
|
||||
</>
|
||||
)}
|
||||
{score.score.computedAt && (
|
||||
<>
|
||||
<dt>Calculé le</dt>
|
||||
<dd>{new Date(score.score.computedAt).toLocaleString('fr-FR')}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FeedbackButton spot={spot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackButton({ spot }) {
|
||||
const [sent, setSent] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (hasSeaweed) => {
|
||||
if (loading || sent) return;
|
||||
setLoading(true);
|
||||
|
||||
const coords = spot.geometry?.coordinates;
|
||||
if (!coords) return;
|
||||
|
||||
await api.feedback.create({
|
||||
lat: coords[1],
|
||||
lng: coords[0],
|
||||
hasSeaweed,
|
||||
coastalPointId: spot.id,
|
||||
}).catch(console.error);
|
||||
|
||||
setLoading(false);
|
||||
setSent(true);
|
||||
};
|
||||
|
||||
if (sent) return <p className="spot-panel__feedback-thanks">Merci pour votre retour !</p>;
|
||||
|
||||
return (
|
||||
<div className="spot-panel__feedback">
|
||||
<p>Vous êtes sur place ?</p>
|
||||
<div className="spot-panel__feedback-btns">
|
||||
<button onClick={() => submit(true)} disabled={loading}>✅ Je vois des sargasses</button>
|
||||
<button onClick={() => submit(false)} disabled={loading}>❌ Pas de sargasses</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
server: {
|
||||
proxy: {
|
||||
// En dev local, proxy /api → Symfony (port 8000)
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../backend/public/spa',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user