feat: système de signalement bug/idée + modal changelog avec limitations
- AppReport entity + migration (type, message, fingerprint SHA-256, created_at) - POST /api/report avec rate-limit 5/heure par fingerprint IP - ReportModal : formulaire type radio (bug/idée) + textarea 1 000 chars max - ChangelogModal : fonctionnalités en prod, roadmap, 5 limitations honnêtes - Deux boutons flottants bas-gauche (? info + ✉ signalement) - Légende remontée pour éviter le chevauchement Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="app">
|
||||
@@ -29,11 +33,34 @@ export default function App() {
|
||||
|
||||
<Legend />
|
||||
|
||||
{/* Boutons flottants bas-gauche */}
|
||||
<div className="app__actions">
|
||||
<button
|
||||
className="app__action-btn app__action-btn--info"
|
||||
onClick={() => setShowChangelog(true)}
|
||||
title="À propos / changelog"
|
||||
aria-label="À propos"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<button
|
||||
className="app__action-btn app__action-btn--report"
|
||||
onClick={() => setShowReport(true)}
|
||||
title="Signaler un bug ou soumettre une idée"
|
||||
aria-label="Signalement"
|
||||
>
|
||||
✉
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SpotPanel
|
||||
spot={selectedSpot}
|
||||
activeStep={activeStep}
|
||||
onClose={() => setSelectedSpot(null)}
|
||||
/>
|
||||
|
||||
{showReport && <ReportModal onClose={() => setShowReport(false)} />}
|
||||
{showChangelog && <ChangelogModal onClose={() => setShowChangelog(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
124
frontend/src/components/ChangelogModal/ChangelogModal.css
Normal file
124
frontend/src/components/ChangelogModal/ChangelogModal.css
Normal file
@@ -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;
|
||||
}
|
||||
101
frontend/src/components/ChangelogModal/ChangelogModal.jsx
Normal file
101
frontend/src/components/ChangelogModal/ChangelogModal.jsx
Normal file
@@ -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 (
|
||||
<div className="chlog-backdrop" onClick={onClose}>
|
||||
<div className="chlog" role="dialog" aria-modal="true" aria-label="Changelog et limitations" onClick={e => e.stopPropagation()}>
|
||||
<button className="chlog__close" onClick={onClose} aria-label="Fermer">✕</button>
|
||||
|
||||
<h2 className="chlog__title">À propos</h2>
|
||||
<p className="chlog__subtitle">Radar Sargasses Caraïbes — open & gratuit</p>
|
||||
|
||||
<section className="chlog__section">
|
||||
<h3 className="chlog__section-title chlog__section-title--green">En production</h3>
|
||||
<ul className="chlog__list">
|
||||
{PRODUCTION.map((item, i) => (
|
||||
<li key={i} className="chlog__item">
|
||||
<span className="chlog__item-icon">{item.icon}</span>
|
||||
<span>{item.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="chlog__section">
|
||||
<h3 className="chlog__section-title chlog__section-title--blue">Prochainement</h3>
|
||||
<ul className="chlog__list">
|
||||
{ROADMAP.map((item, i) => (
|
||||
<li key={i} className="chlog__item chlog__item--muted">
|
||||
<span className="chlog__item-icon">{item.icon}</span>
|
||||
<span>{item.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="chlog__section">
|
||||
<h3 className="chlog__section-title chlog__section-title--orange">Limitations connues</h3>
|
||||
<ul className="chlog__list">
|
||||
{LIMITATIONS.map((item, i) => (
|
||||
<li key={i} className="chlog__limitation">
|
||||
<span className="chlog__limitation-header">
|
||||
<span className="chlog__item-icon">{item.icon}</span>
|
||||
<strong>{item.title}</strong>
|
||||
</span>
|
||||
<span className="chlog__limitation-text">{item.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p className="chlog__footer">
|
||||
Données : ESA Copernicus · Modèle de dérive : courants CMEMS + ERA5
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
128
frontend/src/components/ReportModal/ReportModal.css
Normal file
128
frontend/src/components/ReportModal/ReportModal.css
Normal file
@@ -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; }
|
||||
71
frontend/src/components/ReportModal/ReportModal.jsx
Normal file
71
frontend/src/components/ReportModal/ReportModal.jsx
Normal file
@@ -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 (
|
||||
<div className="rmodal-backdrop" onClick={onClose}>
|
||||
<div className="rmodal" role="dialog" aria-modal="true" aria-label="Signalement" onClick={e => e.stopPropagation()}>
|
||||
<button className="rmodal__close" onClick={onClose} aria-label="Fermer">✕</button>
|
||||
|
||||
{status === 'ok' ? (
|
||||
<div className="rmodal__thanks">
|
||||
<span className="rmodal__thanks-icon">🙏</span>
|
||||
<p>Merci, votre retour a bien été envoyé !</p>
|
||||
</div>
|
||||
) : (
|
||||
<form className="rmodal__form" onSubmit={submit}>
|
||||
<h2 className="rmodal__title">Votre retour</h2>
|
||||
|
||||
<div className="rmodal__type-row">
|
||||
<label className={`rmodal__type-btn${type === 'bug' ? ' rmodal__type-btn--active' : ''}`}>
|
||||
<input type="radio" name="type" value="bug" checked={type === 'bug'} onChange={() => setType('bug')} />
|
||||
🐛 Signaler un bug
|
||||
</label>
|
||||
<label className={`rmodal__type-btn${type === 'idea' ? ' rmodal__type-btn--active' : ''}`}>
|
||||
<input type="radio" name="type" value="idea" checked={type === 'idea'} onChange={() => setType('idea')} />
|
||||
💡 Soumettre une idée
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="rmodal__textarea"
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
placeholder={type === 'bug' ? 'Décrivez le problème observé…' : 'Votre idée ou suggestion…'}
|
||||
maxLength={1000}
|
||||
required
|
||||
minLength={5}
|
||||
rows={5}
|
||||
/>
|
||||
<p className="rmodal__counter">{message.length}/1000</p>
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="rmodal__error">Une erreur est survenue. Réessayez dans un instant.</p>
|
||||
)}
|
||||
|
||||
<button type="submit" className="rmodal__submit" disabled={status === 'loading'}>
|
||||
{status === 'loading' ? 'Envoi…' : 'Envoyer'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user