diff --git a/backend/migrations/Version20260410000000.php b/backend/migrations/Version20260410000000.php
new file mode 100644
index 0000000..b91f20a
--- /dev/null
+++ b/backend/migrations/Version20260410000000.php
@@ -0,0 +1,35 @@
+addSql("CREATE TABLE app_report (
+ id UUID NOT NULL,
+ type VARCHAR(10) NOT NULL,
+ message TEXT NOT NULL,
+ fingerprint VARCHAR(64) DEFAULT NULL,
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ PRIMARY KEY (id)
+ )");
+ $this->addSql('CREATE INDEX idx_report_created_at ON app_report (created_at)');
+ $this->addSql("COMMENT ON COLUMN app_report.id IS '(DC2Type:uuid)'");
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('DROP TABLE app_report');
+ }
+}
diff --git a/backend/public/index.html b/backend/public/index.html
index a429c62..da10695 100644
--- a/backend/public/index.html
+++ b/backend/public/index.html
@@ -8,8 +8,8 @@
-
-
+
+
diff --git a/backend/src/Controller/ReportController.php b/backend/src/Controller/ReportController.php
new file mode 100644
index 0000000..d7149e7
--- /dev/null
+++ b/backend/src/Controller/ReportController.php
@@ -0,0 +1,67 @@
+getContent(), true);
+
+ $type = trim((string) ($body['type'] ?? ''));
+ $message = trim((string) ($body['message'] ?? ''));
+
+ if (!in_array($type, ['bug', 'idea'], true)) {
+ return $this->json(['error' => 'type must be "bug" or "idea"'], 400);
+ }
+ if (strlen($message) < 5) {
+ return $this->json(['error' => 'message too short (min 5 chars)'], 400);
+ }
+ if (strlen($message) > 1000) {
+ return $this->json(['error' => 'message too long (max 1000 chars)'], 400);
+ }
+
+ $fingerprint = hash('sha256', $request->getClientIp() ?? 'unknown');
+
+ // Rate limit : 5 rapports par heure par fingerprint
+ $count = (int) $this->connection->fetchOne(
+ "SELECT COUNT(*) FROM app_report
+ WHERE fingerprint = :fp AND created_at >= NOW() - INTERVAL '1 hour'",
+ ['fp' => $fingerprint]
+ );
+ if ($count >= 5) {
+ return $this->json(['error' => 'Rate limit exceeded. Try again later.'], 429);
+ }
+
+ $report = new AppReport();
+ $report->setType($type);
+ $report->setMessage($message);
+ $report->setFingerprint($fingerprint);
+
+ $this->em->persist($report);
+ $this->em->flush();
+
+ return $this->json(['ok' => true], 201);
+ }
+}
diff --git a/backend/src/Entity/AppReport.php b/backend/src/Entity/AppReport.php
new file mode 100644
index 0000000..801c883
--- /dev/null
+++ b/backend/src/Entity/AppReport.php
@@ -0,0 +1,47 @@
+createdAt = new \DateTimeImmutable();
+ }
+
+ public function getId(): ?Uuid { return $this->id; }
+ public function getType(): string { return $this->type; }
+ public function setType(string $type): static { $this->type = $type; return $this; }
+ public function getMessage(): string { return $this->message; }
+ public function setMessage(string $message): static { $this->message = $message; return $this; }
+ public function getFingerprint(): ?string { return $this->fingerprint; }
+ public function setFingerprint(?string $fingerprint): static { $this->fingerprint = $fingerprint; return $this; }
+ public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
+}
diff --git a/frontend/src/App.css b/frontend/src/App.css
index e33cae1..37a58b8 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -13,6 +13,39 @@ body {
height: 100vh;
}
+/* ── Boutons flottants (signalement + info) ── */
+.app__actions {
+ position: absolute;
+ bottom: 14px;
+ left: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ z-index: 15;
+}
+
+.app__action-btn {
+ width: 36px;
+ height: 36px;
+ border: 2.5px solid #333;
+ border-radius: 50%;
+ font-size: 16px;
+ font-family: 'Fredoka', sans-serif;
+ font-weight: 700;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 3px 3px 0px #333;
+ transition: transform .1s, box-shadow .1s;
+}
+.app__action-btn:hover {
+ transform: translate(-2px, -2px);
+ box-shadow: 5px 5px 0px #333;
+}
+.app__action-btn--info { background: #fffbf0; color: #1a1a2e; }
+.app__action-btn--report { background: #2563eb; color: #fff; }
+
/* Vagues SVG en overlay sur la mer — très subtil, pointer-events none */
.app__wave-overlay {
position: absolute;
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index a87f679..f5a8935 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -3,12 +3,16 @@ import SargassesMap from './components/Map/SargassesMap';
import SpotPanel from './components/SpotPanel/SpotPanel';
import Topbar from './components/Topbar/Topbar';
import Legend from './components/Legend/Legend';
+import ReportModal from './components/ReportModal/ReportModal';
+import ChangelogModal from './components/ChangelogModal/ChangelogModal';
import './App.css';
export default function App() {
- const [selectedSpot, setSelectedSpot] = useState(null);
- const [activeStep, setActiveStep] = useState('now');
- const [flyToTarget, setFlyToTarget] = useState(null);
+ const [selectedSpot, setSelectedSpot] = useState(null);
+ const [activeStep, setActiveStep] = useState('now');
+ const [flyToTarget, setFlyToTarget] = useState(null);
+ const [showReport, setShowReport] = useState(false);
+ const [showChangelog, setShowChangelog] = useState(false);
return (
@@ -29,11 +33,34 @@ export default function App() {
+ {/* Boutons flottants bas-gauche */}
+
+
+
+
+
setSelectedSpot(null)}
/>
+
+ {showReport && setShowReport(false)} />}
+ {showChangelog && setShowChangelog(false)} />}
);
}
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 1bc8086..249d71c 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -43,6 +43,9 @@ export const api = {
status: {
get: () => get('/status'),
},
+ report: {
+ create: (data) => post('/report', data),
+ },
push: {
vapidKey: () => get('/push/vapid-public-key'),
subscribe: (data) => post('/push/subscribe', data),
diff --git a/frontend/src/components/ChangelogModal/ChangelogModal.css b/frontend/src/components/ChangelogModal/ChangelogModal.css
new file mode 100644
index 0000000..63ed47c
--- /dev/null
+++ b/frontend/src/components/ChangelogModal/ChangelogModal.css
@@ -0,0 +1,124 @@
+.chlog-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.chlog {
+ position: relative;
+ background: #fffbf0;
+ border: 3px solid #333;
+ border-radius: 20px;
+ box-shadow: 6px 6px 0px #333;
+ padding: 28px 28px 20px;
+ width: min(520px, 94vw);
+ max-height: 86vh;
+ overflow-y: auto;
+ font-family: 'Fredoka', sans-serif;
+ scrollbar-width: thin;
+}
+
+.chlog__close {
+ position: absolute;
+ top: 12px;
+ right: 14px;
+ background: none;
+ border: none;
+ font-size: 18px;
+ cursor: pointer;
+ color: #555;
+ line-height: 1;
+}
+.chlog__close:hover { color: #111; }
+
+.chlog__title {
+ font-family: 'Fredoka One', cursive;
+ font-size: 22px;
+ color: #1a1a2e;
+ margin-bottom: 2px;
+}
+
+.chlog__subtitle {
+ font-size: 13px;
+ color: #666;
+ margin-bottom: 20px;
+}
+
+.chlog__section {
+ margin-bottom: 20px;
+}
+
+.chlog__section-title {
+ font-family: 'Fredoka One', cursive;
+ font-size: 14px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ padding: 3px 10px;
+ border-radius: 8px;
+ display: inline-block;
+ margin-bottom: 10px;
+}
+.chlog__section-title--green { background: #dcfce7; color: #16a34a; }
+.chlog__section-title--blue { background: #dbeafe; color: #1d4ed8; }
+.chlog__section-title--orange { background: #fff7ed; color: #c2410c; border: 1px solid #fed7aa; }
+
+.chlog__list {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.chlog__item {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ font-size: 14px;
+ color: #333;
+ line-height: 1.4;
+}
+.chlog__item--muted { color: #666; }
+
+.chlog__item-icon {
+ flex-shrink: 0;
+ width: 20px;
+ text-align: center;
+}
+
+.chlog__limitation {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 8px 10px;
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 10px;
+ font-size: 13px;
+}
+
+.chlog__limitation-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: #1a1a2e;
+ font-weight: 600;
+}
+
+.chlog__limitation-text {
+ color: #555;
+ line-height: 1.5;
+ padding-left: 26px;
+}
+
+.chlog__footer {
+ font-size: 11px;
+ color: #aaa;
+ text-align: center;
+ margin-top: 8px;
+ padding-top: 12px;
+ border-top: 1px solid #e5e7eb;
+}
diff --git a/frontend/src/components/ChangelogModal/ChangelogModal.jsx b/frontend/src/components/ChangelogModal/ChangelogModal.jsx
new file mode 100644
index 0000000..f1e2483
--- /dev/null
+++ b/frontend/src/components/ChangelogModal/ChangelogModal.jsx
@@ -0,0 +1,101 @@
+import './ChangelogModal.css';
+
+const PRODUCTION = [
+ { icon: '🛰️', text: 'Détection satellite Sentinel-2 (10 m, confirmé AFAI + SWIR)' },
+ { icon: '📡', text: 'Signal satellite Sentinel-3 (300 m, indicatif — peut inclure du bruit)' },
+ { icon: '🌊', text: 'Simulation de dérive J+2 (modèle courant + vent)' },
+ { icon: '📍', text: 'Score de risque par spot côtier (distance + densité + tendance)' },
+ { icon: '🔔', text: 'Alertes push navigateur par spot' },
+ { icon: '🗓️', text: 'Historique 30 jours en mode ligne du temps' },
+];
+
+const ROADMAP = [
+ { icon: '🏝️', text: 'Distance maritime réelle (évite les faux raccourcis à travers les terres)' },
+ { icon: '🤖', text: 'Amélioration de la qualité du signal Sentinel-3 (filtrage brume, aérosols sahariens)' },
+ { icon: '📊', text: 'Historique long terme et tendances saisonnières' },
+ { icon: '📱', text: 'Application mobile native' },
+];
+
+const LIMITATIONS = [
+ {
+ icon: '☁️',
+ title: 'Couverture nuageuse',
+ text: 'Sentinel-2 ne voit pas à travers les nuages. En saison des pluies, les données peuvent être absentes plusieurs jours d\'affilée.',
+ },
+ {
+ icon: '📡',
+ title: 'Signal Sentinel-3 bruité',
+ text: 'Les polygones jaune pâle (S3) peuvent inclure du soleil réfléchi ou des aérosols sahariens — pas toujours des sargasses. Fiez-vous en priorité à l\'orange vif (S2 confirmé).',
+ },
+ {
+ icon: '📏',
+ title: 'Distance à vol d\'oiseau',
+ text: 'Les distances affichées sont calculées en ligne droite. Un banc à 19 km peut être séparé de vous par une île — la menace réelle dépend des courants.',
+ },
+ {
+ icon: '🕐',
+ title: 'Mise à jour ~quotidienne',
+ text: 'L\'ingestion satellite est automatique mais dépend de la disponibilité des images (1 à 2 passages par jour selon l\'orbite).',
+ },
+ {
+ icon: '🗺️',
+ title: 'Zone couverte',
+ text: 'L\'outil couvre actuellement les Petites Antilles (Saint-Martin à la Barbade). Aucune donnée pour la Martinique nord ou la Caraïbe profonde.',
+ },
+];
+
+export default function ChangelogModal({ onClose }) {
+ return (
+
+
e.stopPropagation()}>
+
+
+
À propos
+
Radar Sargasses Caraïbes — open & gratuit
+
+
+ En production
+
+ {PRODUCTION.map((item, i) => (
+ -
+ {item.icon}
+ {item.text}
+
+ ))}
+
+
+
+
+ Prochainement
+
+ {ROADMAP.map((item, i) => (
+ -
+ {item.icon}
+ {item.text}
+
+ ))}
+
+
+
+
+ Limitations connues
+
+ {LIMITATIONS.map((item, i) => (
+ -
+
+ {item.icon}
+ {item.title}
+
+ {item.text}
+
+ ))}
+
+
+
+
+ Données : ESA Copernicus · Modèle de dérive : courants CMEMS + ERA5
+
+
+
+ );
+}
diff --git a/frontend/src/components/Legend/Legend.css b/frontend/src/components/Legend/Legend.css
index 4877dcf..df9b1e1 100644
--- a/frontend/src/components/Legend/Legend.css
+++ b/frontend/src/components/Legend/Legend.css
@@ -1,6 +1,6 @@
.legend {
position: absolute;
- bottom: 48px;
+ bottom: 110px;
left: 16px;
background: #fffbf0;
border: 3px solid #333;
@@ -63,5 +63,5 @@
.legend__dot--high { background: #ef4444; }
@media (max-width: 480px) {
- .legend { bottom: auto; top: 60px; left: 10px; }
+ .legend { bottom: auto; top: 68px; left: 10px; }
}
diff --git a/frontend/src/components/ReportModal/ReportModal.css b/frontend/src/components/ReportModal/ReportModal.css
new file mode 100644
index 0000000..33f2424
--- /dev/null
+++ b/frontend/src/components/ReportModal/ReportModal.css
@@ -0,0 +1,128 @@
+.rmodal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.rmodal {
+ position: relative;
+ background: #fffbf0;
+ border: 3px solid #333;
+ border-radius: 20px;
+ box-shadow: 6px 6px 0px #333;
+ padding: 28px 28px 24px;
+ width: min(440px, 92vw);
+ font-family: 'Fredoka', sans-serif;
+}
+
+.rmodal__close {
+ position: absolute;
+ top: 12px;
+ right: 14px;
+ background: none;
+ border: none;
+ font-size: 18px;
+ cursor: pointer;
+ color: #555;
+ line-height: 1;
+}
+.rmodal__close:hover { color: #111; }
+
+.rmodal__title {
+ font-family: 'Fredoka One', cursive;
+ font-size: 20px;
+ color: #1a1a2e;
+ margin-bottom: 16px;
+}
+
+.rmodal__type-row {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.rmodal__type-btn {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 8px 12px;
+ border: 2px solid #333;
+ border-radius: 12px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ background: #fff;
+ transition: background .1s;
+}
+.rmodal__type-btn input { display: none; }
+.rmodal__type-btn--active {
+ background: #2563eb;
+ color: #fff;
+ border-color: #1d4ed8;
+}
+.rmodal__type-btn:hover:not(.rmodal__type-btn--active) { background: #f0f4ff; }
+
+.rmodal__textarea {
+ width: 100%;
+ padding: 10px 12px;
+ border: 2px solid #333;
+ border-radius: 12px;
+ font-family: 'Fredoka', sans-serif;
+ font-size: 14px;
+ resize: vertical;
+ background: #fff;
+ outline: none;
+}
+.rmodal__textarea:focus { border-color: #2563eb; }
+
+.rmodal__counter {
+ text-align: right;
+ font-size: 11px;
+ color: #888;
+ margin-top: 4px;
+ margin-bottom: 12px;
+}
+
+.rmodal__error {
+ color: #ef4444;
+ font-size: 13px;
+ margin-bottom: 10px;
+}
+
+.rmodal__submit {
+ width: 100%;
+ padding: 10px;
+ border: 2px solid #333;
+ border-radius: 12px;
+ background: #2563eb;
+ color: #fff;
+ font-family: 'Fredoka', sans-serif;
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+ box-shadow: 3px 3px 0px #333;
+ transition: transform .1s, box-shadow .1s;
+}
+.rmodal__submit:hover:not(:disabled) {
+ transform: translate(-1px, -1px);
+ box-shadow: 4px 4px 0px #333;
+}
+.rmodal__submit:disabled { opacity: 0.6; cursor: not-allowed; }
+
+.rmodal__thanks {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ padding: 16px 0 8px;
+ text-align: center;
+ font-size: 16px;
+ color: #1a1a2e;
+}
+.rmodal__thanks-icon { font-size: 40px; }
diff --git a/frontend/src/components/ReportModal/ReportModal.jsx b/frontend/src/components/ReportModal/ReportModal.jsx
new file mode 100644
index 0000000..a1fcbbb
--- /dev/null
+++ b/frontend/src/components/ReportModal/ReportModal.jsx
@@ -0,0 +1,71 @@
+import { useState } from 'react';
+import { api } from '../../api/client';
+import './ReportModal.css';
+
+export default function ReportModal({ onClose }) {
+ const [type, setType] = useState('bug');
+ const [message, setMessage] = useState('');
+ const [status, setStatus] = useState('idle'); // idle | loading | ok | error
+
+ const submit = async (e) => {
+ e.preventDefault();
+ if (status === 'loading' || status === 'ok') return;
+ setStatus('loading');
+ try {
+ await api.report.create({ type, message });
+ setStatus('ok');
+ } catch {
+ setStatus('error');
+ }
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
+ {status === 'ok' ? (
+
+
🙏
+
Merci, votre retour a bien été envoyé !
+
+ ) : (
+
+ )}
+
+
+ );
+}