Le projet : aller chercher de vraies données
Quatrième projet, on sort de notre bulle : on va parler à un serveur. Tu tapes une ville, et on affiche sa météo en direct, récupérée depuis une API publique (Open-Meteo, sans clé). C'est le premier projet où la donnée ne vient pas de nous.
Et c'est précisément le piège : avec une API, ça marche quand tout va bien. L'IA, elle, ne code que le cas où tout va bien. La connexion qui tombe, la ville qui n'existe pas, l'attente d'une seconde : c'est à toi d'y penser. On code, puis on relit.
fetch() appelle une API et renvoie une Promise : la réponse arrive plus tard. Tout l'enjeu est de gérer ce « plus tard », y compris quand il tourne mal.
- Gérer les 3 états d'un appel réseau : chargement, erreur, succès (et les rendre tous visibles à l'écran).
- Encoder une saisie utilisateur dans une URL avec
encodeURIComponentpour éviter de casser la requête sur les accents et les espaces. - Protéger l'affichage de données externes contre le XSS en utilisant
textContentplutôt queinnerHTML.
- les 3 états sont visibles à l'écran : un spinner pendant le chargement, un message d'erreur clair, la météo en cas de succès ;
- une ville avec un accent ou un espace (« São Paulo », « New York ») fonctionne sans casser l'URL ;
- la console est vide, même si tu coupes ta connexion en pleine recherche.
Prompt 1 : poser le cadre
On cadre la demande : un champ « ville », et au clic on va chercher la météo. On guide même un peu l'IA sur le « comment » (géocodage de la ville, puis prévisions) et sur le « sans clé », pour qu'elle prenne directement le bon chemin :
Crée une mini-app web autonome, un seul fichier HTML (zéro dépendance). Un champ « ville » ; au clic, récupère la météo via l'API publique Open-Meteo (géocodage puis prévisions, sans clé) et affiche la température et le temps qu'il fait. Bilingue FR/EN. Code simple et lisible.
L'IA livre la version « heureuse » :
function search(city) {
fetch('https://geocoding-api.open-meteo.com/v1/search?name=' + city) // ⚠️ ville brute dans l'URL
.then(function (r) { return r.json(); })
.then(function (geo) {
var g = geo.results[0]; // ⚠️ et si results est vide ?
return fetch('https://api.open-meteo.com/v1/forecast?latitude=' + g.latitude + '&longitude=' + g.longitude + '¤t=temperature_2m');
})
.then(function (r) { return r.json(); })
.then(function (w) {
panel.innerHTML = '<h2>' + city + '</h2>' + w.current.temperature_2m + '°C'; // ⚠️ pas de .catch, innerHTML
});
}
Tape « Paris », ça marche, c'est joli. Tape « Pariss », ou coupe le wifi : écran blanc, et une erreur dans la console que l'utilisateur ne verra jamais.
Relis ces deux lignes du code de l'IA avant de descendre :
fetch('https://geocoding-api.open-meteo.com/v1/search?name=' + city)
panel.innerHTML = '<h2>' + city + '</h2>' + w.current.temperature_2m + '°C';
À ton avis, que se passe-t-il si on tape une ville avec un espace ou un accent (« São Paulo », « New York ») ? Et si l'API renvoie une valeur contenant des balises HTML ?
Vérifier ma prédiction
Sans encodeURIComponent, un espace dans la saisie devient un espace brut dans l'URL, ce qui la coupe en deux. Un accent non échappé (« é » en UTF-8) peut produire une URL invalide selon le navigateur. La requête échoue ou renvoie un résultat inattendu, sans aucun message clair pour l'utilisateur.
Pour innerHTML : si la valeur venue de l'API (par exemple le nom d'une ville ou une description météo) contenait une chaîne comme <img src=x onerror="alerte()">, le navigateur l'exécuterait. C'est une injection XSS via une donnée tierce. textContent insère le texte tel quel, sans jamais l'interpréter comme du HTML.
Ma relecture humaine : 3 trucs que l'IA a laissés passer
Voilà le grand classique des projets qui touchent au réseau : la version de l'IA est optimiste. Elle suppose que la requête aboutit, que la ville existe, que la réponse arrive vite. Une vraie appli, elle, passe la moitié de son temps à gérer ce qui rate. C'est là-dessus qu'on va travailler.
1. Le chemin malheureux n'existe pas
Pas d'état de chargement (l'utilisateur attend devant du vide), pas de .catch pour le réseau qui tombe, et geo.results[0] plante si la ville est introuvable. Trois états manquent : chargement, erreur, aucun résultat. Une appli qui parle au réseau passe la moitié de son code à gérer ce qui rate.
2. La donnée vient d'ailleurs : on s'en méfie
Le nom de ville et la réponse de l'API sont des données externes. Les injecter avec innerHTML est une faille XSS de plus. Et la saisie envoyée dans l'URL doit passer par encodeURIComponent (un espace ou un « & » casserait sinon la requête). On affiche avec textContent, on encode dans l'URL.
3. Le résultat doit être annoncé
La zone de résultat est en aria-live="polite" : quand la météo arrive (ou qu'une erreur s'affiche), un lecteur d'écran l'annonce. Et la recherche est un vrai <form> : la touche Entrée fonctionne, pas seulement le clic.
Bonne nouvelle ici : Open-Meteo ne demande pas de clé. Règle d'or quand une API en exige une : ne jamais la mettre dans du JS client (elle serait visible par tout le monde). Une clé passe par un petit serveur intermédiaire.
Prompt 2 : durcir après relecture
On a repéré les trous : pas d'attente affichée, pas de gestion d'erreur, saisie injectée telle quelle. On redonne la main à l'IA, point par point :
Corrige : (1) affiche un état "chargement" pendant l'appel, un message si la ville est introuvable, et un .catch pour les erreurs réseau. (2) Encode la ville avec encodeURIComponent dans l'URL, et affiche le nom + la météo avec textContent (pas innerHTML). (3) Mets la zone de résultat en aria-live="polite" et utilise un vrai <form> (submit).
function search(city) {
showState(loadingMsg, false, true); // 1. on montre le chargement
var url = 'https://geocoding-api.open-meteo.com/v1/search?name='
+ encodeURIComponent(city.trim()); // 2. saisie encodée
fetch(url)
.then(function (r) { if (!r.ok) throw new Error(); return r.json(); })
.then(function (geo) {
if (!geo.results || !geo.results.length) { showState(notFoundMsg, true); return; } // aucun résultat
/* … prévisions, puis affichage avec textContent … */
})
.catch(function () { showState(networkMsg, true); }); // 1. le réseau peut tomber
}
Le même réflexe, encore : ne jamais faire confiance à ce qu'on ne contrôle pas. Le réseau peut tomber, l'API peut répondre n'importe quoi, l'utilisateur peut taper n'importe quoi. Ton code prévoit les trois.
Héberger et tester
- Fichier statique, en ligne d'un dépôt (l'API est appelée depuis le navigateur).
- Teste une ville qui existe, une qui n'existe pas (« azertyville »), puis coupe ta connexion et réessaie : à chaque fois, un message clair, jamais un écran blanc.
- Valide avec Entrée (pas seulement le bouton). Bascule FR / EN.
- Console : zéro erreur non gérée.
Le rendu final
Le code complet (et téléchargeable)
Le fichier entier, exactement celui qui tourne au-dessus.
Télécharger le code (.html · 214 lignes)
Voir le code complet
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mini-dashboard météo</title>
<meta name="description" content="La météo d'une ville en direct, via une API publique. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<!--
============================================================================
MINI-DASHBOARD MÉTÉO — un seul fichier : HTML + CSS + JS, zéro dépendance.
1. <style> : l'apparence (le panneau météo, le bouton, le spinner).
2. <body> : la structure (un formulaire ville + un panneau de résultat).
3. <script> : la logique (appeler l'API Open-Meteo, gérer chargement/erreurs).
Point clé du projet : une appli qui parle au réseau doit gérer TOUT ce qui
rate — l'attente, la ville introuvable, la connexion coupée. Pas juste le cas idéal.
API utilisée : Open-Meteo (gratuite, sans clé).
============================================================================
-->
<style>
:root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; }
body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
.app { width:100%; max-width:540px; }
header { text-align:center; margin-bottom:20px; }
h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
.sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
.lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
.lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; }
.lang-btn[aria-pressed="true"] { background:var(--accent); color:#fff; }
.lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
.search { display:flex; gap:10px; margin-bottom:22px; }
.search input { flex:1; min-height:50px; padding:0 16px; border:1.5px solid var(--border); border-radius:12px; font:inherit; font-size:1rem; color:var(--ink); }
.search input:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
.btn { min-height:50px; padding:0 22px; border-radius:12px; border:1.5px solid transparent; font:inherit; font-weight:700; cursor:pointer; }
.btn-primary { background:var(--accent); color:#fff; box-shadow:0 6px 16px rgba(38,125,66,0.28); }
.btn-primary:hover { background:#1f6a37; }
.btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
/* Le panneau de résultat : il sert à 3 choses (météo, message d'état, erreur). */
.panel { background:linear-gradient(135deg,#0f1923,#1e6f5c); color:#fff; border-radius:18px; padding:30px 26px; min-height:200px; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; box-shadow:0 16px 38px rgba(15,25,35,0.18); }
.panel .city { font-size:1.15rem; font-weight:700; opacity:0.92; margin-bottom:4px; word-break:break-word; }
.panel .icon { font-size:3.4rem; line-height:1; margin:6px 0; }
.panel .temp { font-size:3rem; font-weight:800; letter-spacing:-0.02em; }
.panel .desc { opacity:0.92; margin-top:2px; }
.panel .meta { margin-top:14px; font-size:0.85rem; opacity:0.8; }
.panel.state { font-size:1rem; font-weight:600; opacity:0.95; } /* mode "message" (chargement…) */
.panel.error { background:linear-gradient(135deg,#3a1c1c,#7d2d2d); } /* mode "erreur" (fond rouge) */
.spinner { width:30px; height:30px; border:3px solid rgba(255,255,255,0.3); border-top-color:#fff; border-radius:50%; animation:spin .8s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
.credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:24px; }
.credit a { color:var(--accent); }
@media (prefers-reduced-motion: reduce){ .spinner{animation:none;} }
</style>
</head>
<body>
<main class="app">
<header>
<h1 data-i18n="title">Météo en direct</h1>
<p class="sub" data-i18n="sub">Tape une ville, vois sa météo via une API publique.</p>
<div class="lang-switch" role="group" aria-label="Langue / Language">
<button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
<button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
</div>
</header>
<!-- Un <form> : valider avec Entrée fonctionne, pas seulement le bouton. -->
<form class="search" id="form">
<input id="city" type="text" maxlength="60" value="Paris" data-i18n-ph="ph" placeholder="Ville (ex. Lyon)" aria-label="Ville">
<button class="btn btn-primary" type="submit" data-i18n="go">Voir</button>
</form>
<!-- aria-live : le résultat (ou l'erreur) est annoncé par un lecteur d'écran. -->
<section class="panel" id="panel" aria-live="polite"></section>
<p class="credit" data-i18n="credit">Données : Open-Meteo · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a></p>
</main>
<script>
(function () {
'use strict';
/* ---------------------------------------------------------------------------
1. LES TEXTES (FR / EN) + LA TABLE DES CODES MÉTÉO.
--------------------------------------------------------------------------- */
var UI = {
fr: { title:"Météo en direct", sub:"Tape une ville, vois sa météo via une API publique.", go:"Voir", ph:"Ville (ex. Lyon)",
credit:'Données : Open-Meteo · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a>',
loading:"Chargement…", notfound:"Ville introuvable. Vérifie l'orthographe.", neterr:"Pas de réponse. Vérifie ta connexion et réessaie.",
wind:"Vent", at:"à" },
en: { title:"Live weather", sub:"Type a city, see its weather via a public API.", go:"Go", ph:"City (e.g. Lyon)",
credit:'Data: Open-Meteo · A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course',
loading:"Loading…", notfound:"City not found. Check the spelling.", neterr:"No response. Check your connection and retry.",
wind:"Wind", at:"at" }
};
// L'API renvoie un code chiffré (norme WMO). On le traduit en [icône, libellé FR, libellé EN].
var WMO = {
0:['☀️','Ciel clair','Clear sky'], 1:['🌤️','Plutôt clair','Mainly clear'], 2:['⛅','Partiellement nuageux','Partly cloudy'],
3:['☁️','Couvert','Overcast'], 45:['🌫️','Brouillard','Fog'], 48:['🌫️','Brouillard givrant','Rime fog'],
51:['🌦️','Bruine légère','Light drizzle'], 53:['🌦️','Bruine','Drizzle'], 55:['🌦️','Bruine dense','Dense drizzle'],
61:['🌧️','Pluie faible','Light rain'], 63:['🌧️','Pluie','Rain'], 65:['🌧️','Forte pluie','Heavy rain'],
71:['🌨️','Neige faible','Light snow'], 73:['🌨️','Neige','Snow'], 75:['❄️','Forte neige','Heavy snow'],
80:['🌦️','Averses','Showers'], 81:['🌧️','Averses','Showers'], 82:['⛈️','Fortes averses','Violent showers'],
95:['⛈️','Orage','Thunderstorm'], 96:['⛈️','Orage grêle','Thunderstorm w/ hail'], 99:['⛈️','Orage violent','Severe thunderstorm']
};
/* ---------------------------------------------------------------------------
2. LES ÉLÉMENTS + LA LANGUE.
--------------------------------------------------------------------------- */
var panel = document.getElementById('panel');
var form = document.getElementById('form');
var cityInput = document.getElementById('city');
var lang = 'fr';
try { lang = localStorage.getItem('weather-lang') || 'fr'; } catch (e) {}
if (lang !== 'fr' && lang !== 'en') lang = 'fr';
/* ---------------------------------------------------------------------------
3. L'AFFICHAGE — le panneau a 2 modes : un message (état) ou la météo.
--------------------------------------------------------------------------- */
// Mode "message" : chargement, erreur, ville introuvable. Avec un spinner optionnel.
function showState(msg, isError, spinner) {
panel.className = 'panel state' + (isError ? ' error' : '');
panel.innerHTML = '';
if (spinner) { var s = document.createElement('div'); s.className = 'spinner'; s.setAttribute('aria-hidden','true'); panel.appendChild(s); }
var p = document.createElement('div'); p.textContent = msg; p.style.marginTop = spinner ? '12px' : '0';
panel.appendChild(p);
}
// Petit raccourci pour créer un <div class="…">texte</div>.
function el(cls, text) { var d = document.createElement('div'); d.className = cls; if (text != null) d.textContent = text; return d; }
// Mode "météo" : on affiche la ville, l'icône, la température, le vent.
function showWeather(place, cur) {
var code = WMO[cur.weather_code] || ['🌡️','—','—'];
panel.className = 'panel';
panel.innerHTML = '';
// textContent partout : "place" vient de l'API (donnée externe), on ne l'injecte jamais comme du HTML.
panel.appendChild(el('city', place));
panel.appendChild(el('icon', code[0]));
panel.appendChild(el('temp', Math.round(cur.temperature_2m) + '°C'));
panel.appendChild(el('desc', lang === 'fr' ? code[1] : code[2]));
panel.appendChild(el('meta', UI[lang].wind + ' : ' + Math.round(cur.wind_speed_10m) + ' km/h'));
}
/* ---------------------------------------------------------------------------
4. L'APPEL RÉSEAU — chercher une ville puis sa météo, en gérant les échecs.
Deux requêtes : d'abord trouver les coordonnées (géocodage), puis la météo.
--------------------------------------------------------------------------- */
function search(city) {
city = (city || '').trim();
if (!city) return;
showState(UI[lang].loading, false, true); // on montre TOUT DE SUITE qu'on attend
// encodeURIComponent : la saisie peut contenir un espace ou un "&" ; on l'encode pour ne pas casser l'URL.
var geoUrl = 'https://geocoding-api.open-meteo.com/v1/search?name=' + encodeURIComponent(city) + '&count=1&language=' + lang;
fetch(geoUrl)
.then(function (r) { if (!r.ok) throw new Error('geo'); return r.json(); })
.then(function (geo) {
// Cas "aucun résultat" : la ville n'existe pas -> message clair, et on s'arrête là.
if (!geo.results || !geo.results.length) { showState(UI[lang].notfound, true, false); return null; }
var g = geo.results[0];
var place = g.name + (g.country ? ', ' + g.country : '');
// 2e requête : la météo actuelle à ces coordonnées.
var wUrl = 'https://api.open-meteo.com/v1/forecast?latitude=' + g.latitude + '&longitude=' + g.longitude + '¤t=temperature_2m,weather_code,wind_speed_10m';
return fetch(wUrl).then(function (r) { if (!r.ok) throw new Error('forecast'); return r.json(); })
.then(function (w) { showWeather(place, w.current); });
})
// .catch attrape TOUTE erreur de la chaîne : connexion coupée, réponse invalide…
.catch(function () { showState(UI[lang].neterr, true, false); });
}
/* ---------------------------------------------------------------------------
5. LES ACTIONS + LE DÉMARRAGE.
--------------------------------------------------------------------------- */
form.addEventListener('submit', function (e) { e.preventDefault(); search(cityInput.value); });
function setLang(next) {
lang = next;
try { localStorage.setItem('weather-lang', lang); } catch (e) {}
document.documentElement.lang = lang;
var t = UI[lang];
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n'); if (!t[k]) return;
if (k === 'credit') el.innerHTML = t[k]; else el.textContent = t[k];
});
var ph = document.querySelector('[data-i18n-ph]'); if (ph) ph.placeholder = t.ph;
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
});
}
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
// En changeant de langue, on relance la recherche pour afficher la météo dans la bonne langue.
b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); search(cityInput.value); });
});
setLang(lang);
search('Paris'); // une ville par défaut au chargement, pour ne pas montrer un panneau vide
})();
</script>
</body>
</html>
À toi de jouer
La meilleure façon d'ancrer les réflexes, c'est de continuer à faire grandir le projet. Quelques pistes ouvertes, pour t'approprier le code :
- Affiche les prévisions sur 3 jours (Open-Meteo renvoie un champ
dailyavec un tableau de températures et de codes météo). - Un bouton « ma position » via la géolocalisation du navigateur (pense au refus de l'utilisateur : la promesse peut rejeter).
- Garde la dernière ville recherchée dans
localStoragepour la pré-remplir au rechargement.
Ajoute un bouton « ma position » qui utilise navigator.geolocation.getCurrentPosition() pour récupérer les coordonnées GPS du navigateur, puis appelle directement l'API de prévisions (sans passer par le géocodage). Tente-le seul d'abord. Tu ne déplies le corrigé qu'après avoir essayé.
Tu as réussi si :
- le clic sur le bouton affiche un état de chargement (pas un écran blanc) ;
- si l'utilisateur refuse la géolocalisation, un message clair s'affiche (pas une erreur en console) ;
- la météo s'affiche avec
textContent, sans aucuninnerHTMLpour les données de l'API.
Voir une solution possible
geoBtn.addEventListener('click', function () {
// 1. on affiche tout de suite le chargement : l'utilisateur sait qu'il se passe quelque chose
showState(loadingMsg, false, true);
if (!navigator.geolocation) {
// le navigateur ne supporte pas la géolocalisation (rare, mais prévoir)
showState(notFoundMsg, true);
return;
}
navigator.geolocation.getCurrentPosition(
function (pos) {
// succès : on a les coordonnées, on appelle directement l'API de prévisions
var lat = pos.coords.latitude;
var lon = pos.coords.longitude;
var url = 'https://api.open-meteo.com/v1/forecast'
+ '?latitude=' + lat
+ '&longitude=' + lon
+ '¤t=temperature_2m,weather_code';
fetch(url)
.then(function (r) { if (!r.ok) throw new Error(); return r.json(); })
.then(function (w) {
// affichage avec textContent : la donnée externe ne touche jamais au HTML
cityEl.textContent = 'Ma position';
tempEl.textContent = w.current.temperature_2m + '°C';
showResult();
})
.catch(function () { showState(networkMsg, true); });
},
function () {
// refus ou erreur de géolocalisation : message visible, pas un console.log
showState(notFoundMsg, true);
}
);
});
Le réflexe clé : getCurrentPosition prend deux callbacks, pas un seul. Le second reçoit l'erreur (refus, délai, position indisponible). Sans lui, le refus de l'utilisateur reste silencieux, exactement comme le .catch vide du prompt 1.
À chaque ajout, repose-toi les trois questions du projet : que se passe-t-il si l'appel échoue ? si la donnée est vide ? si l'utilisateur refuse ? C'est ce qui sépare un projet de démo d'une vraie appli.
The project: fetching real data
Fourth project, we leave our bubble: we're going to talk to a server. You type a city, and we show its live weather, pulled from a public API (Open-Meteo, no key). It's the first project where the data doesn't come from us.
And that's exactly the trap: with an API, it works when everything goes right. The AI only codes the everything-goes-right case. The dropped connection, the city that doesn't exist, the one-second wait: that's on you. We code, then we review.
fetch() calls an API and returns a Promise: the answer arrives later. The whole challenge is handling that "later", including when it goes wrong.
- Handle the 3 states of a network call: loading, error, success — and make all three visible on screen.
- Encode user input into a URL with
encodeURIComponentso accents and spaces don't break the request. - Protect the display of external data against XSS by using
textContentinstead ofinnerHTML.
- all 3 states are visible on screen: a spinner while loading, a clear error message, weather on success;
- a city with an accent or a space ("São Paulo", "New York") works without breaking the URL;
- the console is empty, even if you cut your connection mid-search.
Prompt 1: set the frame
We frame the request: a "city" field, and on click we go fetch the weather. We even nudge the AI on the "how" (geocode the city, then forecast) and on "no key", so it takes the right path straight away:
Create a standalone web mini-app, a single HTML file (zero dependencies). A "city" field; on click, fetch the weather via the public Open-Meteo API (geocoding then forecast, no key) and show the temperature and conditions. Bilingual FR/EN. Simple, readable code.
The AI ships the "happy" version:
function search(city) {
fetch('https://geocoding-api.open-meteo.com/v1/search?name=' + city) // ⚠️ raw city in URL
.then(function (r) { return r.json(); })
.then(function (geo) {
var g = geo.results[0]; // ⚠️ what if results is empty?
return fetch('https://api.open-meteo.com/v1/forecast?latitude=' + g.latitude + '&longitude=' + g.longitude + '¤t=temperature_2m');
})
.then(function (r) { return r.json(); })
.then(function (w) {
panel.innerHTML = '<h2>' + city + '</h2>' + w.current.temperature_2m + '°C'; // ⚠️ no .catch, innerHTML
});
}
Type "Paris", it works, it's pretty. Type "Pariss", or cut the wifi: blank screen, and a console error the user will never see.
Re-read these two lines from the AI's code before scrolling down:
fetch('https://geocoding-api.open-meteo.com/v1/search?name=' + city)
panel.innerHTML = '<h2>' + city + '</h2>' + w.current.temperature_2m + '°C';
What do you think happens if you type a city with a space or an accent ("São Paulo", "New York")? And if the API returns a value containing HTML tags?
Check my prediction
Without encodeURIComponent, a space in the input ends up as a raw space in the URL, which splits it in two. An unescaped accent character may produce an invalid URL depending on the browser. The request fails or returns unexpected data — with no clear message for the user.
For innerHTML: if a value from the API (say, a city name or weather description) contained a string like <img src=x onerror="alert()">, the browser would execute it. That's XSS via third-party data. textContent inserts text as text — never interpreted as HTML.
My human review: 3 things the AI let slip
Here's the great classic of anything that touches the network: the AI's version is optimistic. It assumes the request succeeds, the city exists, the response comes fast. A real app spends half its time handling what fails. That's what we'll work on.
1. The unhappy path doesn't exist
No loading state (the user stares at emptiness), no .catch for a dropped network, and geo.results[0] crashes if the city isn't found. Three states are missing: loading, error, no result. An app that talks to the network spends half its code handling what fails.
2. The data comes from outside: distrust it
The city name and the API response are external data. Injecting them with innerHTML is one more XSS hole. And the input sent into the URL must go through encodeURIComponent (a space or an "&" would otherwise break the request). Show with textContent, encode in the URL.
3. The result must be announced
The result area is aria-live="polite": when the weather arrives (or an error shows), a screen reader announces it. And the search is a real <form>: the Enter key works, not just the click.
Good news here: Open-Meteo needs no key. Golden rule when an API requires one: never put it in client JS (everyone would see it). A key goes through a small intermediate server.
Prompt 2: harden after review
We've spotted the gaps: no waiting state shown, no error handling, input injected as-is. We hand it back to the AI, point by point:
Fix: (1) show a "loading" state during the call, a message if the city isn't found, and a .catch for network errors. (2) Encode the city with encodeURIComponent in the URL, and show the name + weather with textContent (not innerHTML). (3) Make the result area aria-live="polite" and use a real <form> (submit).
function search(city) {
showState(loadingMsg, false, true); // 1. show loading
var url = 'https://geocoding-api.open-meteo.com/v1/search?name='
+ encodeURIComponent(city.trim()); // 2. encoded input
fetch(url)
.then(function (r) { if (!r.ok) throw new Error(); return r.json(); })
.then(function (geo) {
if (!geo.results || !geo.results.length) { showState(notFoundMsg, true); return; } // no result
/* … forecast, then display with textContent … */
})
.catch(function () { showState(networkMsg, true); }); // 1. the network can drop
}
The same reflex, again: never trust what you don't control. The network can drop, the API can return anything, the user can type anything. Your code plans for all three.
Host and test
- Static file, online in one drop (the API is called from the browser).
- Test a city that exists, one that doesn't ("azertyville"), then cut your connection and retry: each time, a clear message, never a blank screen.
- Submit with Enter (not just the button). Toggle FR / EN.
- Console: zero unhandled error.
The finished result
The full code (and downloadable)
The entire file, exactly the one running above.
Download the code (.html · 214 lines)
View the full code
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mini-dashboard météo</title>
<meta name="description" content="La météo d'une ville en direct, via une API publique. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<!--
============================================================================
MINI-DASHBOARD MÉTÉO — un seul fichier : HTML + CSS + JS, zéro dépendance.
1. <style> : l'apparence (le panneau météo, le bouton, le spinner).
2. <body> : la structure (un formulaire ville + un panneau de résultat).
3. <script> : la logique (appeler l'API Open-Meteo, gérer chargement/erreurs).
Point clé du projet : une appli qui parle au réseau doit gérer TOUT ce qui
rate — l'attente, la ville introuvable, la connexion coupée. Pas juste le cas idéal.
API utilisée : Open-Meteo (gratuite, sans clé).
============================================================================
-->
<style>
:root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; }
body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
.app { width:100%; max-width:540px; }
header { text-align:center; margin-bottom:20px; }
h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
.sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
.lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
.lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; }
.lang-btn[aria-pressed="true"] { background:var(--accent); color:#fff; }
.lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
.search { display:flex; gap:10px; margin-bottom:22px; }
.search input { flex:1; min-height:50px; padding:0 16px; border:1.5px solid var(--border); border-radius:12px; font:inherit; font-size:1rem; color:var(--ink); }
.search input:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
.btn { min-height:50px; padding:0 22px; border-radius:12px; border:1.5px solid transparent; font:inherit; font-weight:700; cursor:pointer; }
.btn-primary { background:var(--accent); color:#fff; box-shadow:0 6px 16px rgba(38,125,66,0.28); }
.btn-primary:hover { background:#1f6a37; }
.btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
/* Le panneau de résultat : il sert à 3 choses (météo, message d'état, erreur). */
.panel { background:linear-gradient(135deg,#0f1923,#1e6f5c); color:#fff; border-radius:18px; padding:30px 26px; min-height:200px; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; box-shadow:0 16px 38px rgba(15,25,35,0.18); }
.panel .city { font-size:1.15rem; font-weight:700; opacity:0.92; margin-bottom:4px; word-break:break-word; }
.panel .icon { font-size:3.4rem; line-height:1; margin:6px 0; }
.panel .temp { font-size:3rem; font-weight:800; letter-spacing:-0.02em; }
.panel .desc { opacity:0.92; margin-top:2px; }
.panel .meta { margin-top:14px; font-size:0.85rem; opacity:0.8; }
.panel.state { font-size:1rem; font-weight:600; opacity:0.95; } /* mode "message" (chargement…) */
.panel.error { background:linear-gradient(135deg,#3a1c1c,#7d2d2d); } /* mode "erreur" (fond rouge) */
.spinner { width:30px; height:30px; border:3px solid rgba(255,255,255,0.3); border-top-color:#fff; border-radius:50%; animation:spin .8s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
.credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:24px; }
.credit a { color:var(--accent); }
@media (prefers-reduced-motion: reduce){ .spinner{animation:none;} }
</style>
</head>
<body>
<main class="app">
<header>
<h1 data-i18n="title">Météo en direct</h1>
<p class="sub" data-i18n="sub">Tape une ville, vois sa météo via une API publique.</p>
<div class="lang-switch" role="group" aria-label="Langue / Language">
<button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
<button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
</div>
</header>
<!-- Un <form> : valider avec Entrée fonctionne, pas seulement le bouton. -->
<form class="search" id="form">
<input id="city" type="text" maxlength="60" value="Paris" data-i18n-ph="ph" placeholder="Ville (ex. Lyon)" aria-label="Ville">
<button class="btn btn-primary" type="submit" data-i18n="go">Voir</button>
</form>
<!-- aria-live : le résultat (ou l'erreur) est annoncé par un lecteur d'écran. -->
<section class="panel" id="panel" aria-live="polite"></section>
<p class="credit" data-i18n="credit">Données : Open-Meteo · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a></p>
</main>
<script>
(function () {
'use strict';
/* ---------------------------------------------------------------------------
1. LES TEXTES (FR / EN) + LA TABLE DES CODES MÉTÉO.
--------------------------------------------------------------------------- */
var UI = {
fr: { title:"Météo en direct", sub:"Tape une ville, vois sa météo via une API publique.", go:"Voir", ph:"Ville (ex. Lyon)",
credit:'Données : Open-Meteo · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a>',
loading:"Chargement…", notfound:"Ville introuvable. Vérifie l'orthographe.", neterr:"Pas de réponse. Vérifie ta connexion et réessaie.",
wind:"Vent", at:"à" },
en: { title:"Live weather", sub:"Type a city, see its weather via a public API.", go:"Go", ph:"City (e.g. Lyon)",
credit:'Data: Open-Meteo · A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course',
loading:"Loading…", notfound:"City not found. Check the spelling.", neterr:"No response. Check your connection and retry.",
wind:"Wind", at:"at" }
};
// L'API renvoie un code chiffré (norme WMO). On le traduit en [icône, libellé FR, libellé EN].
var WMO = {
0:['☀️','Ciel clair','Clear sky'], 1:['🌤️','Plutôt clair','Mainly clear'], 2:['⛅','Partiellement nuageux','Partly cloudy'],
3:['☁️','Couvert','Overcast'], 45:['🌫️','Brouillard','Fog'], 48:['🌫️','Brouillard givrant','Rime fog'],
51:['🌦️','Bruine légère','Light drizzle'], 53:['🌦️','Bruine','Drizzle'], 55:['🌦️','Bruine dense','Dense drizzle'],
61:['🌧️','Pluie faible','Light rain'], 63:['🌧️','Pluie','Rain'], 65:['🌧️','Forte pluie','Heavy rain'],
71:['🌨️','Neige faible','Light snow'], 73:['🌨️','Neige','Snow'], 75:['❄️','Forte neige','Heavy snow'],
80:['🌦️','Averses','Showers'], 81:['🌧️','Averses','Showers'], 82:['⛈️','Fortes averses','Violent showers'],
95:['⛈️','Orage','Thunderstorm'], 96:['⛈️','Orage grêle','Thunderstorm w/ hail'], 99:['⛈️','Orage violent','Severe thunderstorm']
};
/* ---------------------------------------------------------------------------
2. LES ÉLÉMENTS + LA LANGUE.
--------------------------------------------------------------------------- */
var panel = document.getElementById('panel');
var form = document.getElementById('form');
var cityInput = document.getElementById('city');
var lang = 'fr';
try { lang = localStorage.getItem('weather-lang') || 'fr'; } catch (e) {}
if (lang !== 'fr' && lang !== 'en') lang = 'fr';
/* ---------------------------------------------------------------------------
3. L'AFFICHAGE — le panneau a 2 modes : un message (état) ou la météo.
--------------------------------------------------------------------------- */
// Mode "message" : chargement, erreur, ville introuvable. Avec un spinner optionnel.
function showState(msg, isError, spinner) {
panel.className = 'panel state' + (isError ? ' error' : '');
panel.innerHTML = '';
if (spinner) { var s = document.createElement('div'); s.className = 'spinner'; s.setAttribute('aria-hidden','true'); panel.appendChild(s); }
var p = document.createElement('div'); p.textContent = msg; p.style.marginTop = spinner ? '12px' : '0';
panel.appendChild(p);
}
// Petit raccourci pour créer un <div class="…">texte</div>.
function el(cls, text) { var d = document.createElement('div'); d.className = cls; if (text != null) d.textContent = text; return d; }
// Mode "météo" : on affiche la ville, l'icône, la température, le vent.
function showWeather(place, cur) {
var code = WMO[cur.weather_code] || ['🌡️','—','—'];
panel.className = 'panel';
panel.innerHTML = '';
// textContent partout : "place" vient de l'API (donnée externe), on ne l'injecte jamais comme du HTML.
panel.appendChild(el('city', place));
panel.appendChild(el('icon', code[0]));
panel.appendChild(el('temp', Math.round(cur.temperature_2m) + '°C'));
panel.appendChild(el('desc', lang === 'fr' ? code[1] : code[2]));
panel.appendChild(el('meta', UI[lang].wind + ' : ' + Math.round(cur.wind_speed_10m) + ' km/h'));
}
/* ---------------------------------------------------------------------------
4. L'APPEL RÉSEAU — chercher une ville puis sa météo, en gérant les échecs.
Deux requêtes : d'abord trouver les coordonnées (géocodage), puis la météo.
--------------------------------------------------------------------------- */
function search(city) {
city = (city || '').trim();
if (!city) return;
showState(UI[lang].loading, false, true); // on montre TOUT DE SUITE qu'on attend
// encodeURIComponent : la saisie peut contenir un espace ou un "&" ; on l'encode pour ne pas casser l'URL.
var geoUrl = 'https://geocoding-api.open-meteo.com/v1/search?name=' + encodeURIComponent(city) + '&count=1&language=' + lang;
fetch(geoUrl)
.then(function (r) { if (!r.ok) throw new Error('geo'); return r.json(); })
.then(function (geo) {
// Cas "aucun résultat" : la ville n'existe pas -> message clair, et on s'arrête là.
if (!geo.results || !geo.results.length) { showState(UI[lang].notfound, true, false); return null; }
var g = geo.results[0];
var place = g.name + (g.country ? ', ' + g.country : '');
// 2e requête : la météo actuelle à ces coordonnées.
var wUrl = 'https://api.open-meteo.com/v1/forecast?latitude=' + g.latitude + '&longitude=' + g.longitude + '¤t=temperature_2m,weather_code,wind_speed_10m';
return fetch(wUrl).then(function (r) { if (!r.ok) throw new Error('forecast'); return r.json(); })
.then(function (w) { showWeather(place, w.current); });
})
// .catch attrape TOUTE erreur de la chaîne : connexion coupée, réponse invalide…
.catch(function () { showState(UI[lang].neterr, true, false); });
}
/* ---------------------------------------------------------------------------
5. LES ACTIONS + LE DÉMARRAGE.
--------------------------------------------------------------------------- */
form.addEventListener('submit', function (e) { e.preventDefault(); search(cityInput.value); });
function setLang(next) {
lang = next;
try { localStorage.setItem('weather-lang', lang); } catch (e) {}
document.documentElement.lang = lang;
var t = UI[lang];
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n'); if (!t[k]) return;
if (k === 'credit') el.innerHTML = t[k]; else el.textContent = t[k];
});
var ph = document.querySelector('[data-i18n-ph]'); if (ph) ph.placeholder = t.ph;
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
});
}
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
// En changeant de langue, on relance la recherche pour afficher la météo dans la bonne langue.
b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); search(cityInput.value); });
});
setLang(lang);
search('Paris'); // une ville par défaut au chargement, pour ne pas montrer un panneau vide
})();
</script>
</body>
</html>
Your turn
The best way to lock in these reflexes is to keep growing the project. A few open ideas to make it yours:
- Show a 3-day forecast (Open-Meteo returns a
dailyfield with arrays of temperatures and weather codes). - A "my location" button via the browser's geolocation — and plan for the user's refusal: the promise can reject.
- Keep the last searched city in
localStorageto pre-fill the field on reload.
Add a "my location" button that uses navigator.geolocation.getCurrentPosition() to grab the GPS coordinates from the browser, then calls the forecast API directly (skipping the geocoding step). Try it on your own first. Only unfold the solution after you've tried.
You've succeeded if:
- clicking the button shows a loading state — never a blank screen;
- if the user denies geolocation, a clear message appears (not a console error);
- the weather is displayed with
textContent, with noinnerHTMLfor API data.
See one possible solution
geoBtn.addEventListener('click', function () {
// 1. show loading immediately — the user knows something is happening
showState(loadingMsg, false, true);
if (!navigator.geolocation) {
// browser doesn't support geolocation (rare, but plan for it)
showState(notFoundMsg, true);
return;
}
navigator.geolocation.getCurrentPosition(
function (pos) {
// success: we have coordinates, call the forecast API directly
var lat = pos.coords.latitude;
var lon = pos.coords.longitude;
var url = 'https://api.open-meteo.com/v1/forecast'
+ '?latitude=' + lat
+ '&longitude=' + lon
+ '¤t=temperature_2m,weather_code';
fetch(url)
.then(function (r) { if (!r.ok) throw new Error(); return r.json(); })
.then(function (w) {
// textContent: external data never touches the HTML parser
cityEl.textContent = 'My location';
tempEl.textContent = w.current.temperature_2m + '°C';
showResult();
})
.catch(function () { showState(networkMsg, true); });
},
function () {
// denial or geolocation error: visible message, not a bare console.log
showState(notFoundMsg, true);
}
);
});
The key reflex: getCurrentPosition takes two callbacks, not one. The second receives the error (denial, timeout, unavailable position). Without it, the user's refusal is silent — exactly like the empty .catch in prompt 1.
On every addition, ask the project's three questions: what happens if the call fails? if the data is empty? if the user refuses? That's what separates a demo project from a real app.
Tu lui as demandé d'ajouter un .catch. Elle te renvoie ça en disant « les erreurs réseau sont gérées ». Accepter ou rejeter ?
fetch(url)
.then(function (r) { return r.json(); }) // pas de test sur r.ok
.then(function (geo) {
var g = geo.results[0]; // plante si results est vide
panel.innerHTML = g.name; // donnée externe en innerHTML
})
.catch(function (e) { console.log(e); }); // l'erreur va dans la console
.catch existe, mais il avale l'erreur dans la console : l'utilisateur, lui, ne voit toujours qu'un écran figé. Le .catch doit appeler un showState(networkMsg, true) visible. Pire, deux trous restent : aucun test if (!r.ok) (une réponse 404 passe pour un succès), geo.results[0] plante toujours si la ville est introuvable, et innerHTML réinjecte une donnée externe (XSS). C'est exactement le piège du projet : un .catch n'est pas une gestion d'erreur, c'est juste l'endroit où la mettre.Sans remonter dans le projet : quels sont les trois états que tout appel à une API doit gérer en plus du cas « tout va bien », et pourquoi encode-t-on la ville avec encodeURIComponent avant de l'écrire dans l'URL ?
geo.results est vide), et erreur réseau (un .catch visible, pas un simple console.log). On encode la ville avec encodeURIComponent parce qu'un espace ou un « & » dans la saisie casserait l'URL ; et on l'affiche avec textContent, pas innerHTML, car c'est une donnée externe (faille XSS).Ton dashboard interroge une vraie API météo et affiche le temps en direct. Au projet suivant, on passe au fun pur : un petit jeu de réflexe où tu mesures ta vitesse de réaction au centième de seconde.
Leçon 5 : Petit jeu de réflexe →