feat: localize Pokédex to French (UI, names, types, abilities)

- lib/locale-fr.ts: baked FR names for 151 Pokémon, 18 types, 114 abilities
  (generated once from PokeAPI — no extra runtime locale requests)
- pokeapi.ts: map Pokémon + ability names to FR in the data layer
- accent-insensitive search (evoli matches Évoli)
- all UI strings, stat labels (PV/ATT/DÉF…) and html lang=fr
This commit is contained in:
Julien Calixte
2026-06-24 19:20:25 +02:00
parent fa5ebbfe92
commit bb2b421053
6 changed files with 349 additions and 38 deletions

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />

View File

@@ -8,6 +8,7 @@ import {
type PokemonListItem,
TYPE_COLORS,
} from "@/lib/pokeapi"
import { TYPE_FR } from "@/lib/locale-fr"
import { useFavorites } from "@/composables/useFavorites"
import PokemonCard from "@/components/PokemonCard.vue"
import PokemonModal from "@/components/PokemonModal.vue"
@@ -34,6 +35,14 @@ const detailError = ref<string | null>(null)
const detailCache = new Map<number, PokemonDetail>()
const typeCache = new Map<string, Set<number>>()
// Lower-case and strip accents so "evoli" matches "Évoli".
function norm(s: string): string {
return s
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase()
}
const filtered = computed(() => {
let items = list.value
if (showFavoritesOnly.value) items = items.filter((p) => isFavorite(p.id))
@@ -41,10 +50,12 @@ const filtered = computed(() => {
const ids = typeIds.value
items = items.filter((p) => ids.has(p.id))
}
const q = search.value.trim().toLowerCase().replace(/^#/, "")
if (q) {
const raw = search.value.trim().toLowerCase().replace(/^#/, "")
if (raw) {
const q = norm(raw)
items = items.filter(
(p) => p.name.includes(q) || String(p.id) === q || String(p.id).padStart(3, "0") === q,
(p) =>
norm(p.name).includes(q) || String(p.id) === raw || String(p.id).padStart(3, "0") === raw,
)
}
return items
@@ -58,7 +69,7 @@ async function load(): Promise<void> {
try {
list.value = await fetchPokedex()
} catch (e) {
loadError.value = e instanceof Error ? e.message : "Failed to load Pokédex"
loadError.value = e instanceof Error ? e.message : "Échec du chargement du Pokédex"
} finally {
loading.value = false
}
@@ -105,7 +116,7 @@ async function openDetail(id: number): Promise<void> {
detailCache.set(id, d)
detail.value = d
} catch (e) {
detailError.value = e instanceof Error ? e.message : "Failed to load this Pokémon"
detailError.value = e instanceof Error ? e.message : "Échec du chargement de ce Pokémon"
} finally {
detailLoading.value = false
}
@@ -154,18 +165,18 @@ onUnmounted(() => window.removeEventListener("keydown", onKeydown))
<input
v-model="search"
type="search"
placeholder="Search name or #001…"
placeholder="Rechercher un nom ou #001…"
class="input input-bordered rounded-none border-2 border-base-300 focus:border-primary grow text-lg"
aria-label="Search Pokémon"
aria-label="Rechercher un Pokémon"
/>
<select
:value="selectedType ?? ''"
class="select select-bordered rounded-none border-2 border-base-300 text-lg"
aria-label="Filter by type"
aria-label="Filtrer par type"
@change="selectType(($event.target as HTMLSelectElement).value)"
>
<option value="">All types</option>
<option v-for="t in TYPES" :key="t" :value="t" class="capitalize">{{ t }}</option>
<option value="">Tous les types</option>
<option v-for="t in TYPES" :key="t" :value="t">{{ TYPE_FR[t] ?? t }}</option>
</select>
<button
class="btn rounded-none border-2 text-lg"
@@ -173,20 +184,20 @@ onUnmounted(() => window.removeEventListener("keydown", onKeydown))
:aria-pressed="showFavoritesOnly"
@click="showFavoritesOnly = !showFavoritesOnly"
>
Favorites
Favoris
</button>
</div>
<p v-if="typeLoading" class="text-center text-lg text-base-content/50 py-2">Filtering</p>
<p v-if="typeLoading" class="text-center text-lg text-base-content/50 py-2">Filtrage</p>
<div v-if="loading" class="text-center py-20 text-2xl text-base-content/60">
Booting Pokédex
Démarrage du Pokédex
</div>
<div v-else-if="loadError" class="text-center py-20 space-y-4">
<p class="text-xl text-error">{{ loadError }}</p>
<button class="btn btn-primary rounded-none font-display text-xs" @click="load">
Retry
Réessayer
</button>
</div>
@@ -194,7 +205,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKeydown))
v-else-if="filtered.length === 0"
class="text-center py-20 text-2xl text-base-content/60"
>
No Pokémon found.
Aucun Pokémon trouvé.
</div>
<div v-else class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">

View File

@@ -23,7 +23,7 @@ const dexNo = `#${String(props.id).padStart(3, "0")}`
<button
class="absolute top-1 right-1 z-10 leading-none text-xl p-1"
:class="favorite ? 'text-primary' : 'text-base-300 hover:text-primary'"
:aria-label="favorite ? `Remove ${name} from favorites` : `Add ${name} to favorites`"
:aria-label="favorite ? `Retirer ${name} des favoris` : `Ajouter ${name} aux favoris`"
:aria-pressed="favorite"
@click.stop="emit('toggle-favorite', id)"
>
@@ -40,6 +40,6 @@ const dexNo = `#${String(props.id).padStart(3, "0")}`
/>
<span class="text-base-content/50 text-lg leading-none">{{ dexNo }}</span>
<span class="capitalize text-xl leading-tight text-center">{{ name }}</span>
<span class="text-xl leading-tight text-center">{{ name }}</span>
</div>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { type PokemonDetail, TYPE_COLORS } from "@/lib/pokeapi"
import { TYPE_FR } from "@/lib/locale-fr"
const props = defineProps<{
open: boolean
@@ -15,12 +16,12 @@ const emit = defineEmits<{
}>()
const STAT_LABELS: Record<string, string> = {
hp: "HP",
attack: "ATK",
defense: "DEF",
"special-attack": "SP.ATK",
"special-defense": "SP.DEF",
speed: "SPD",
hp: "PV",
attack: "ATT",
defense: "DÉF",
"special-attack": "A.SPÉ",
"special-defense": "D.SPÉ",
speed: "VIT",
}
// Highest base stat in Gen I (Chansey's HP, 250) — round up for the bar scale.
@@ -40,23 +41,23 @@ function barWidth(value: number): string {
<div
class="modal-box rounded-none border-4 border-primary shadow-[8px_8px_0_0_#1a1a1a] max-w-lg"
>
<div v-if="loading" class="py-16 text-center text-xl">Loading</div>
<div v-if="loading" class="py-16 text-center text-xl">Chargement</div>
<div v-else-if="error" class="py-16 text-center space-y-4">
<p class="text-xl text-error">{{ error }}</p>
<button class="btn btn-sm rounded-none" @click="emit('close')">Close</button>
<button class="btn btn-sm rounded-none" @click="emit('close')">Fermer</button>
</div>
<div v-else-if="detail" class="space-y-5">
<header class="flex items-center justify-between gap-3">
<h2 class="font-display text-base text-primary capitalize">{{ detail.name }}</h2>
<h2 class="font-display text-base text-primary">{{ detail.name }}</h2>
<div class="flex items-center gap-2">
<span class="text-base-content/50 text-xl">{{ dexNo(detail.id) }}</span>
<button
class="text-2xl leading-none"
:class="favorite ? 'text-primary' : 'text-base-300 hover:text-primary'"
:aria-pressed="favorite"
:aria-label="favorite ? 'Remove from favorites' : 'Add to favorites'"
:aria-label="favorite ? 'Retirer des favoris' : 'Ajouter aux favoris'"
@click="emit('toggle-favorite', detail.id)"
>
{{ favorite ? "★" : "☆" }}
@@ -80,25 +81,25 @@ function barWidth(value: number): string {
class="px-2 py-1 text-sm uppercase text-white rounded-none"
:style="{ backgroundColor: TYPE_COLORS[t] ?? '#777' }"
>
{{ t }}
{{ TYPE_FR[t] ?? t }}
</span>
</div>
<dl class="text-lg grid grid-cols-2 gap-x-4">
<dt class="text-base-content/50">Height</dt>
<dt class="text-base-content/50">Taille</dt>
<dd>{{ (detail.height / 10).toFixed(1) }} m</dd>
<dt class="text-base-content/50">Weight</dt>
<dt class="text-base-content/50">Poids</dt>
<dd>{{ (detail.weight / 10).toFixed(1) }} kg</dd>
</dl>
</div>
</div>
<section>
<h3 class="text-base-content/50 text-lg mb-1">Abilities</h3>
<p class="capitalize text-xl">{{ detail.abilities.join(", ").replace(/-/g, " ") }}</p>
<h3 class="text-base-content/50 text-lg mb-1">Talents</h3>
<p class="text-xl">{{ detail.abilities.join(", ") }}</p>
</section>
<section class="space-y-1.5">
<h3 class="text-base-content/50 text-lg">Base stats</h3>
<h3 class="text-base-content/50 text-lg">Statistiques</h3>
<div v-for="s in detail.stats" :key="s.name" class="flex items-center gap-2 text-lg">
<span class="w-16 shrink-0 text-base-content/70">{{
STAT_LABELS[s.name] ?? s.name
@@ -112,7 +113,7 @@ function barWidth(value: number): string {
<div class="modal-action">
<button class="btn rounded-none font-display text-xs" @click="emit('close')">
Close
Fermer
</button>
</div>
</div>

295
src/lib/locale-fr.ts Normal file
View File

@@ -0,0 +1,295 @@
// French (fr) display names for Gen-I Pokémon, types, and abilities.
// Generated once from PokeAPI (https://pokeapi.co) and baked in so the UI needs
// no extra locale requests at runtime. To refresh, re-run /tmp/gen-fr.mjs.
export const POKEMON_FR: Record<number, string> = {
1: "Bulbizarre",
2: "Herbizarre",
3: "Florizarre",
4: "Salamèche",
5: "Reptincel",
6: "Dracaufeu",
7: "Carapuce",
8: "Carabaffe",
9: "Tortank",
10: "Chenipan",
11: "Chrysacier",
12: "Papilusion",
13: "Aspicot",
14: "Coconfort",
15: "Dardargnan",
16: "Roucool",
17: "Roucoups",
18: "Roucarnage",
19: "Rattata",
20: "Rattatac",
21: "Piafabec",
22: "Rapasdepic",
23: "Abo",
24: "Arbok",
25: "Pikachu",
26: "Raichu",
27: "Sabelette",
28: "Sablaireau",
29: "Nidoran♀",
30: "Nidorina",
31: "Nidoqueen",
32: "Nidoran♂",
33: "Nidorino",
34: "Nidoking",
35: "Mélofée",
36: "Mélodelfe",
37: "Goupix",
38: "Feunard",
39: "Rondoudou",
40: "Grodoudou",
41: "Nosferapti",
42: "Nosferalto",
43: "Mystherbe",
44: "Ortide",
45: "Rafflesia",
46: "Paras",
47: "Parasect",
48: "Mimitoss",
49: "Aéromite",
50: "Taupiqueur",
51: "Triopikeur",
52: "Miaouss",
53: "Persian",
54: "Psykokwak",
55: "Akwakwak",
56: "Férosinge",
57: "Colossinge",
58: "Caninos",
59: "Arcanin",
60: "Ptitard",
61: "Têtarte",
62: "Tartard",
63: "Abra",
64: "Kadabra",
65: "Alakazam",
66: "Machoc",
67: "Machopeur",
68: "Mackogneur",
69: "Chétiflor",
70: "Boustiflor",
71: "Empiflor",
72: "Tentacool",
73: "Tentacruel",
74: "Racaillou",
75: "Gravalanch",
76: "Grolem",
77: "Ponyta",
78: "Galopa",
79: "Ramoloss",
80: "Flagadoss",
81: "Magnéti",
82: "Magnéton",
83: "Canarticho",
84: "Doduo",
85: "Dodrio",
86: "Otaria",
87: "Lamantine",
88: "Tadmorv",
89: "Grotadmorv",
90: "Kokiyas",
91: "Crustabri",
92: "Fantominus",
93: "Spectrum",
94: "Ectoplasma",
95: "Onix",
96: "Soporifik",
97: "Hypnomade",
98: "Krabby",
99: "Krabboss",
100: "Voltorbe",
101: "Électrode",
102: "Noeunoeuf",
103: "Noadkoko",
104: "Osselait",
105: "Ossatueur",
106: "Kicklee",
107: "Tygnon",
108: "Excelangue",
109: "Smogo",
110: "Smogogo",
111: "Rhinocorne",
112: "Rhinoféros",
113: "Leveinard",
114: "Saquedeneu",
115: "Kangourex",
116: "Hypotrempe",
117: "Hypocéan",
118: "Poissirène",
119: "Poissoroy",
120: "Stari",
121: "Staross",
122: "M. Mime",
123: "Insécateur",
124: "Lippoutou",
125: "Élektek",
126: "Magmar",
127: "Scarabrute",
128: "Tauros",
129: "Magicarpe",
130: "Léviator",
131: "Lokhlass",
132: "Métamorph",
133: "Évoli",
134: "Aquali",
135: "Voltali",
136: "Pyroli",
137: "Porygon",
138: "Amonita",
139: "Amonistar",
140: "Kabuto",
141: "Kabutops",
142: "Ptéra",
143: "Ronflex",
144: "Artikodin",
145: "Électhor",
146: "Sulfura",
147: "Minidraco",
148: "Draco",
149: "Dracolosse",
150: "Mewtwo",
151: "Mew",
}
export const TYPE_FR: Record<string, string> = {
electric: "Électrik",
fire: "Feu",
ice: "Glace",
poison: "Poison",
water: "Eau",
grass: "Plante",
normal: "Normal",
fighting: "Combat",
ground: "Sol",
psychic: "Psy",
flying: "Vol",
dragon: "Dragon",
rock: "Roche",
dark: "Ténèbres",
bug: "Insecte",
ghost: "Spectre",
steel: "Acier",
fairy: "Fée",
}
export const ABILITY_FR: Record<string, string> = {
adaptability: "Adaptabilité",
aftermath: "Boom Final",
analytic: "Analyste",
"anger-point": "Colérique",
anticipation: "Anticipation",
"arena-trap": "Piège Sable",
"battle-armor": "Armurbaston",
"big-pecks": "Cœur de Coq",
blaze: "Brasier",
chlorophyll: "Chlorophylle",
"clear-body": "Corps Sain",
"cloud-nine": "Ciel Gris",
competitive: "Battant",
"compound-eyes": "Œil Composé",
"cursed-body": "Corps Maudit",
"cute-charm": "Joli Sourire",
damp: "Moiteur",
defiant: "Acharné",
download: "Télécharge",
drought: "Sécheresse",
"dry-skin": "Peau Sèche",
"early-bird": "Matinal",
"effect-spore": "Pose Spore",
filter: "Filtre",
"flame-body": "Corps Ardent",
"flash-fire": "Torche",
forewarn: "Prédiction",
"friend-guard": "Garde-Ami",
frisk: "Fouille",
gluttony: "Gloutonnerie",
guts: "Cran",
harvest: "Récolte",
healer: "Cœur Soin",
hustle: "Agitation",
hydration: "Hydratation",
"hyper-cutter": "Hyper Cutter",
"ice-body": "Corps Gel",
illuminate: "Lumiattirance",
immunity: "Vaccin",
imposter: "Imposteur",
infiltrator: "Infiltration",
"inner-focus": "Attention",
insomnia: "Insomnia",
intimidate: "Intimidation",
"iron-fist": "Poing de Fer",
justified: "Cœur Noble",
"keen-eye": "Regard Vif",
"leaf-guard": "Feuille Garde",
levitate: "Lévitation",
"lightning-rod": "Paratonnerre",
limber: "Échauffement",
"liquid-ooze": "Suintement",
"magic-guard": "Garde Magik",
"magnet-pull": "Magnépiège",
"marvel-scale": "Écaille Spéciale",
"mold-breaker": "Brise Moule",
moxie: "Impudence",
multiscale: "Multiécaille",
"natural-cure": "Médic Nature",
"neutralizing-gas": "Gaz Inhibiteur",
"no-guard": "Annule Garde",
oblivious: "Benêt",
overcoat: "Envelocape",
overgrow: "Engrais",
"own-tempo": "Tempo Perso",
pickup: "Ramassage",
"poison-point": "Point Poison",
"poison-touch": "Toxitouche",
pressure: "Pression",
"quick-feet": "Pied Véloce",
"rain-dish": "Cuvette",
rattled: "Phobique",
reckless: "Téméraire",
regenerator: "Régé-Force",
rivalry: "Rivalité",
"rock-head": "Tête de Roc",
"run-away": "Fuite",
"sand-force": "Force Sable",
"sand-rush": "Baigne Sable",
"sand-veil": "Voile Sable",
scrappy: "Querelleur",
"serene-grace": "Sérénité",
"shed-skin": "Mue",
"sheer-force": "Sans Limite",
"shell-armor": "Coque Armure",
"shield-dust": "Écran Poudre",
"skill-link": "Multi-Coups",
sniper: "Sniper",
"snow-cloak": "Rideau Neige",
"solar-power": "Force Soleil",
soundproof: "Anti-Bruit",
static: "Statik",
steadfast: "Impassible",
stench: "Puanteur",
"sticky-hold": "Glu",
sturdy: "Fermeté",
swarm: "Essaim",
"swift-swim": "Glissade",
synchronize: "Synchro",
"tangled-feet": "Pieds Confus",
technician: "Technicien",
"thick-fat": "Isograisse",
"tinted-lens": "Lentiteintée",
torrent: "Torrent",
trace: "Calque",
unaware: "Inconscient",
unburden: "Délestage",
unnerve: "Tension",
"vital-spirit": "Esprit Vital",
"volt-absorb": "Absorbe-Volt",
"water-absorb": "Absorbe-Eau",
"water-veil": "Ignifu-Voile",
"weak-armor": "Armurouillée",
"wonder-skin": "Peau Miracle",
}

View File

@@ -1,5 +1,6 @@
// Thin typed client over the public PokeAPI (https://pokeapi.co).
// Scoped to Generation I (#001#151) — the classic 8-bit Red/Blue dex.
import { ABILITY_FR, POKEMON_FR } from "./locale-fr"
const API = "https://pokeapi.co/api/v2"
const GEN1_LIMIT = 151
@@ -81,7 +82,10 @@ export async function fetchPokedex(): Promise<PokemonListItem[]> {
const res = await fetch(`${API}/pokemon?limit=${GEN1_LIMIT}`)
if (!res.ok) throw new Error(`PokeAPI list failed (${res.status})`)
const data = (await res.json()) as RawList
return data.results.map((r) => ({ id: idFromUrl(r.url), name: r.name }))
return data.results.map((r) => {
const id = idFromUrl(r.url)
return { id, name: POKEMON_FR[id] ?? r.name }
})
}
export async function fetchPokemon(id: number): Promise<PokemonDetail> {
@@ -90,12 +94,12 @@ export async function fetchPokemon(id: number): Promise<PokemonDetail> {
const d = (await res.json()) as RawDetail
return {
id: d.id,
name: d.name,
name: POKEMON_FR[d.id] ?? d.name,
height: d.height,
weight: d.weight,
types: d.types.map((t) => t.type.name),
stats: d.stats.map((s) => ({ name: s.stat.name, value: s.base_stat })),
abilities: d.abilities.map((a) => a.ability.name),
abilities: d.abilities.map((a) => ABILITY_FR[a.ability.name] ?? a.ability.name),
sprite: d.sprites.front_default ?? spriteUrl(d.id),
}
}