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.
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.
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
- Affiche les prévisions sur 3 jours (Open-Meteo renvoie un champ
daily). - Un bouton « ma position » via la géolocalisation du navigateur (pense au refus de l'utilisateur).
- Garde la dernière ville recherchée dans localStorage (tu sais faire, projet 3).
À chaque ajout : que se passe-t-il si l'appel échoue ? si la donnée est vide ? si l'utilisateur refuse ?
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.
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.
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
- Show a 3-day forecast (Open-Meteo returns a
dailyfield). - A "my location" button via the browser's geolocation (handle the user's refusal).
- Keep the last searched city in localStorage (you know how, project 3).
On every addition: what happens if the call fails? if the data is empty? if the user refuses?
Your dashboard queries a real weather API and shows conditions in real time. Next up, pure fun: a small reaction game where you measure your reflexes down to the hundredth of a second.
Lesson 5: Reaction game →