app: refonte JWE v2 - SvelteKit 2 + Svelte 5 + MapLibre, 4 modes de jeu, API anti-triche, ecran Cas B 'Pwen blinde', i18n FR/GCF, PWA

This commit is contained in:
sucupira
2026-07-16 21:05:22 -04:00
parent ee85a10e20
commit 923fa2869f
38 changed files with 8570 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
build
.svelte-kit
.env
.env.*
!.env.example
.vite
+28
View File
@@ -0,0 +1,28 @@
MIT License
Copyright (c) 2026 ORGANISATION KA INTERNATIONALE (OKI) & contributeurs JWE
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Note : les photographies affichées par l'application restent la propriété de
leurs auteurs respectifs et sont distribuées sous leurs licences Wikimedia
Commons (voir content/credits.md). Les extraits d'articles Wikipédia sont
sous licence CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/deed.fr).
+2140
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "jwe",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"start": "node build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.1.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
},
"dependencies": {
"maplibre-gl": "^5.1.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b3d2e" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+421
View File
@@ -0,0 +1,421 @@
<script lang="ts">
import { onMount } from 'svelte';
import { env } from '$env/dynamic/public';
import { t } from '$lib/i18n/store.svelte';
import { MAX_BOUNDS, MIN_ZOOM, REGION_BOUNDS, REGIONS } from '$lib/map/regions';
import styleOki from '$lib/map/style-oki.json';
import type { Coordonnees, Region } from '$lib/types';
import type { Map as MLMap, Marker, StyleSpecification } from 'maplibre-gl';
interface Paire {
from: Coordonnees;
to: Coordonnees;
label?: string;
}
interface MarqueurInfo {
id: string;
lat: number;
lon: number;
label: string;
}
interface Props {
guess?: Coordonnees | null;
real?: Coordonnees | null;
interactive?: boolean;
pairs?: Paire[];
markers?: MarqueurInfo[];
onguess?: (c: Coordonnees) => void;
onvalidate?: () => void;
onmarkerclick?: (id: string) => void;
}
let {
guess = $bindable(null),
real = null,
interactive = true,
pairs = [],
markers = [],
onguess,
onvalidate,
onmarkerclick
}: Props = $props();
let conteneur: HTMLDivElement;
let map: MLMap | null = null;
let ml: typeof import('maplibre-gl') | null = null;
let guessMarker: Marker | null = null;
let realMarker: Marker | null = null;
let explorerMarkers: Marker[] = [];
let raf = 0;
const SRC_LIGNE = 'jwe-ligne';
const SRC_PAIRES = 'jwe-paires';
const reducedMotion =
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function styleUrl(): string | StyleSpecification {
// Bascule vers un tileserver auto-hébergé sans changement de code.
if (env.PUBLIC_TILES_URL) return env.PUBLIC_TILES_URL;
return styleOki as unknown as StyleSpecification;
}
function ensureSource(id: string): void {
if (map && !map.getSource(id)) {
map.addSource(id, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
}
}
function setGeoJSON(id: string, data: GeoJSON.FeatureCollection): void {
const src = map?.getSource(id) as import('maplibre-gl').GeoJSONSource | undefined;
src?.setData(data);
}
function ligneFeature(from: Coordonnees, to: Coordonnees, props: GeoJSON.GeoJsonProperties): GeoJSON.Feature {
return {
type: 'Feature',
properties: props,
geometry: { type: 'LineString', coordinates: [[from.lon, from.lat], [to.lon, to.lat]] }
};
}
function pointFeature(c: Coordonnees, props: GeoJSON.GeoJsonProperties): GeoJSON.Feature {
return {
type: 'Feature',
properties: props,
geometry: { type: 'Point', coordinates: [c.lon, c.lat] }
};
}
function dessinerRevelation(): void {
if (!map || !ml || !real) return;
ensureSource(SRC_LIGNE);
if (!map.getLayer('jwe-ligne-line')) {
map.addLayer({
id: 'jwe-ligne-line',
type: 'line',
source: SRC_LIGNE,
paint: { 'line-color': '#e4572e', 'line-width': 3, 'line-dasharray': [2, 1.5] }
});
}
const features: GeoJSON.Feature[] = guess ? [ligneFeature(guess, real, null)] : [];
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features });
// Marqueur vert = position réelle
realMarker?.remove();
realMarker = new ml.Marker({ color: '#0b3d2e' }).setLngLat([real.lon, real.lat]).addTo(map);
// Le marqueur du joueur « vole » vers la position réelle (sauf reduced-motion)
if (guessMarker && guess) {
const from = { ...guess };
if (reducedMotion) {
guessMarker.setLngLat([real.lon, real.lat]);
} else {
const debut = performance.now();
const duree = 1400;
const step = (now: number) => {
if (!guessMarker) return;
const k = Math.min(1, (now - debut) / duree);
const e = 1 - Math.pow(1 - k, 3);
guessMarker.setLngLat([
from.lon + (real.lon - from.lon) * e,
from.lat + (real.lat - from.lat) * e
]);
if (k < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
const bounds = new ml.LngLatBounds(
[Math.min(from.lon, real.lon), Math.min(from.lat, real.lat)],
[Math.max(from.lon, real.lon), Math.max(from.lat, real.lat)]
);
map.fitBounds(bounds, { padding: 80, duration: reducedMotion ? 0 : 900, maxZoom: 12 });
} else {
map.easeTo({ center: [real.lon, real.lat], zoom: 11, duration: reducedMotion ? 0 : 900 });
}
}
function nettoyerRevelation(): void {
cancelAnimationFrame(raf);
realMarker?.remove();
realMarker = null;
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features: [] });
}
function syncGuessMarker(): void {
if (!map || !ml) return;
if (guess) {
if (!guessMarker) {
guessMarker = new ml.Marker({ color: '#e4572e', draggable: true })
.setLngLat([guess.lon, guess.lat])
.addTo(map);
guessMarker.on('dragend', () => {
const ll = guessMarker?.getLngLat();
if (ll) {
guess = { lat: ll.lat, lon: ll.lng };
onguess?.(guess);
}
});
} else {
guessMarker.setLngLat([guess.lon, guess.lat]);
}
guessMarker.setDraggable(interactive && !real);
} else {
guessMarker?.remove();
guessMarker = null;
}
}
function syncPaires(): void {
if (!map || !ml) return;
ensureSource(SRC_PAIRES);
if (!map.getLayer('jwe-paires-line')) {
map.addLayer({
id: 'jwe-paires-line',
type: 'line',
source: SRC_PAIRES,
filter: ['==', ['geometry-type'], 'LineString'],
paint: { 'line-color': '#5a6b62', 'line-width': 2, 'line-dasharray': [2, 1.5] }
});
map.addLayer({
id: 'jwe-paires-points',
type: 'circle',
source: SRC_PAIRES,
filter: ['==', ['geometry-type'], 'Point'],
paint: {
'circle-radius': 7,
'circle-color': ['get', 'couleur'],
'circle-stroke-color': '#fffdf8',
'circle-stroke-width': 2
}
});
}
const features: GeoJSON.Feature[] = [];
for (const p of pairs) {
features.push(ligneFeature(p.from, p.to, null));
features.push(pointFeature(p.from, { couleur: '#e4572e' })); // joueur
features.push(pointFeature(p.to, { couleur: '#0b3d2e' })); // réel
}
setGeoJSON(SRC_PAIRES, { type: 'FeatureCollection', features });
if (pairs.length > 0) {
const b = new ml.LngLatBounds();
for (const p of pairs) {
b.extend([p.from.lon, p.from.lat]);
b.extend([p.to.lon, p.to.lat]);
}
map.fitBounds(b, { padding: 60, duration: reducedMotion ? 0 : 800 });
}
}
function syncExplorerMarkers(): void {
if (!map || !ml) return;
for (const m of explorerMarkers) m.remove();
explorerMarkers = [];
for (const info of markers) {
const el = document.createElement('button');
el.type = 'button';
el.className = 'marqueur-explorer';
el.setAttribute('aria-label', info.label);
const mk = new ml.Marker({ element: el, color: '#175e70' })
.setLngLat([info.lon, info.lat])
.addTo(map!);
el.addEventListener('click', (e) => {
e.stopPropagation();
onmarkerclick?.(info.id);
});
explorerMarkers.push(mk);
}
}
function sauterVers(region: Region): void {
const b = REGION_BOUNDS[region];
map?.fitBounds(
[
[b[0], b[1]],
[b[2], b[3]]
],
{ duration: reducedMotion ? 0 : 800 }
);
}
function onKeydown(e: KeyboardEvent): void {
if (!map) return;
const pas = 140; // pixels de déplacement
if (e.key === 'Enter') {
e.preventDefault();
onvalidate?.();
return;
}
const dirs: Record<string, [number, number]> = {
ArrowUp: [0, -pas],
ArrowDown: [0, pas],
ArrowLeft: [-pas, 0],
ArrowRight: [pas, 0]
};
const d = dirs[e.key];
if (!d) return;
e.preventDefault();
if (guessMarker && interactive && !real) {
// Déplacer le marqueur : conversion écran → géo pour garder un pas constant
const ll = guessMarker.getLngLat();
const pt = map.project([ll.lng, ll.lat]);
const nv = map.unproject([pt.x + d[0], pt.y + d[1]]);
guess = { lat: nv.lat, lon: nv.lng };
onguess?.(guess);
} else {
map.panBy(d, { duration: 100 });
}
}
onMount(() => {
let detruit = false;
// Lazy load : MapLibre (JS + CSS) n'est chargé que sur les écrans avec carte.
void (async () => {
const [mod] = await Promise.all([import('maplibre-gl'), import('maplibre-gl/dist/maplibre-gl.css')]);
if (detruit) return;
ml = mod;
map = new mod.Map({
container: conteneur,
style: styleUrl(),
center: [-58, 0],
zoom: 3.6,
minZoom: MIN_ZOOM,
maxBounds: [
[MAX_BOUNDS[0], MAX_BOUNDS[1]],
[MAX_BOUNDS[2], MAX_BOUNDS[3]]
],
attributionControl: { compact: true },
// Libellés FR/créole des contrôles natifs (sinon « Zoom in/out » en anglais)
locale: {
'NavigationControl.ZoomIn': t().carte.zoomIn,
'NavigationControl.ZoomOut': t().carte.zoomOut
}
});
map.addControl(new mod.NavigationControl({ showCompass: false }), 'top-left');
map.on('click', (e) => {
if (!interactive || real) return;
guess = { lat: e.lngLat.lat, lon: e.lngLat.lng };
onguess?.(guess);
});
map.on('load', () => {
syncGuessMarker();
syncPaires();
syncExplorerMarkers();
if (real) dessinerRevelation();
});
})();
return () => {
detruit = true;
cancelAnimationFrame(raf);
map?.remove();
map = null;
};
});
let ancienReal: Coordonnees | null = null;
$effect(() => {
if (!map || !map.loaded()) return;
if (real && !ancienReal) dessinerRevelation();
if (!real && ancienReal) nettoyerRevelation();
ancienReal = real;
});
$effect(() => {
if (map?.loaded()) syncGuessMarker();
void guess;
void interactive;
});
$effect(() => {
if (map?.loaded()) syncPaires();
void pairs;
});
$effect(() => {
if (map?.loaded()) syncExplorerMarkers();
void markers;
});
</script>
<div class="carte-wrap">
<!-- La zone carte est volontairement focusable : flèches = déplacer le marqueur, Entrée = valider -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
<div
class="carte"
bind:this={conteneur}
role="application"
aria-label={t().carte.ariaLabel}
tabindex="0"
onkeydown={onKeydown}
></div>
<div class="sauts" role="group" aria-label={t().carte.sautRegion}>
{#each REGIONS as region (region)}
<button type="button" class="saut" onclick={() => sauterVers(region)}>
{t().regions[region]}
</button>
{/each}
</div>
</div>
<style>
.carte-wrap {
position: relative;
width: 100%;
height: 100%;
}
.carte {
width: 100%;
height: 100%;
border-radius: var(--oki-radius);
overflow: hidden;
}
.sauts {
position: absolute;
bottom: 0.75rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: center;
padding: 0 0.5rem;
}
.saut {
background: rgba(255, 253, 248, 0.94);
color: var(--oki-vert);
border: 1px solid var(--oki-sable-fonce);
border-radius: 999px;
padding: 0.35rem 0.8rem;
font-size: 0.8rem;
font-weight: 650;
cursor: pointer;
box-shadow: var(--oki-ombre);
}
.saut:hover {
background: var(--oki-vert-doux);
}
:global(.marqueur-explorer) {
width: 26px;
height: 26px;
border-radius: 50%;
border: 2.5px solid var(--oki-blanc);
background: var(--oki-bleu);
cursor: pointer;
box-shadow: var(--oki-ombre);
}
:global(.marqueur-explorer:hover),
:global(.marqueur-explorer:focus-visible) {
background: var(--oki-vert-clair);
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { t } from '$lib/i18n/store.svelte';
import { getLang, setLang, type Lang } from '$lib/i18n/store.svelte';
</script>
<div class="lang-switch" role="group" aria-label="Langue / Lang">
{#each ['fr', 'gcf'] as l (l)}
<button
type="button"
class:actif={getLang() === l}
aria-pressed={getLang() === l}
onclick={() => setLang(l as Lang)}
>
{t().langue[l as Lang]}
</button>
{/each}
</div>
<style>
.lang-switch {
display: flex;
gap: 0.25rem;
background: var(--oki-vert-doux);
border-radius: 999px;
padding: 0.2rem;
}
button {
border: none;
background: transparent;
color: var(--oki-vert);
border-radius: 999px;
padding: 0.3rem 0.75rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
button.actif {
background: var(--oki-vert);
color: #fff;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import type { Photo } from '$lib/types';
interface Props {
photo: Photo;
revealed?: boolean;
}
let { photo, revealed = false }: Props = $props();
</script>
<figure class="photo-panel">
<img src={photo.url} alt={photo.alt} loading="lazy" decoding="async" />
{#if revealed}
<figcaption>{photo.credit}</figcaption>
{/if}
</figure>
<style>
.photo-panel {
position: relative;
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--oki-vert);
border-radius: var(--oki-radius);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
figcaption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.4rem 0.8rem;
font-size: 0.72rem;
color: #fff;
background: linear-gradient(transparent, rgba(11, 61, 46, 0.85));
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<script lang="ts">
import WikiFiche from './WikiFiche.svelte';
import { t } from '$lib/i18n/store.svelte';
import type { GuessResult } from '$lib/types';
interface Props {
result: GuessResult;
labelBouton?: string;
creditPhoto?: string;
onsuivant?: () => void;
}
let { result, labelBouton, creditPhoto, onsuivant }: Props = $props();
</script>
<section class="resultat" aria-live="polite">
<header class="score-bloc">
<p class="bravo">{result.score > 0 ? t().micro.bravo : t().micro.kiteSaYe}</p>
<p class="score">
<span class="valeur">{result.score}</span>
<span class="unite">{t().resultat.points}</span>
</p>
{#if result.distanceKm !== null}
<p class="distance">{t().resultat.distance} : {result.distanceKm} {t().resultat.km}</p>
{/if}
<p class="lieu">
<strong>{result.nom}</strong>
{#if result.nom_creole && result.nom_creole !== result.nom}
<span class="creole"> · {result.nom_creole}</span>
{/if}
<br />
{t().resultat.commune} : {result.commune}{t().regions[result.region]}
</p>
</header>
{#key result.nom}
<WikiFiche
nom={result.nom}
wikidataId={result.wikidata_id}
tonPositif={result.score > 0 ? t().resultat.trouve : t().resultat.aDecouvrir}
/>
{/key}
{#if creditPhoto}
<p class="credit-photo">{creditPhoto}</p>
{/if}
{#if onsuivant}
<button type="button" class="btn btn-primaire suivant" onclick={onsuivant}>
{labelBouton ?? t().resultat.suivant}
</button>
{/if}
</section>
<style>
.resultat {
display: flex;
flex-direction: column;
gap: 1rem;
}
.score-bloc {
text-align: center;
}
.bravo {
margin: 0;
font-size: 1.3rem;
font-weight: 700;
color: var(--oki-vert-clair);
}
.score {
margin: 0.2rem 0;
}
.score .valeur {
font-size: 2.6rem;
font-weight: 800;
color: var(--oki-vert);
}
.score .unite {
font-size: 1rem;
color: var(--oki-encre-doux);
margin-left: 0.3rem;
}
.distance {
margin: 0;
color: var(--oki-encre-doux);
}
.credit-photo {
margin: 0;
font-size: 0.72rem;
color: var(--oki-encre-doux);
text-align: center;
}
.lieu {
margin: 0.5rem 0 0;
}
.creole {
color: var(--oki-vert-clair);
font-style: italic;
}
.suivant {
align-self: center;
min-width: 220px;
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
dureeMs?: number; // fenêtre du bonus temps (60 s)
actif?: boolean;
}
let { dureeMs = 60000, actif = true }: Props = $props();
let ecoule = $state(0);
let intervalle: ReturnType<typeof setInterval> | null = null;
const depart = Date.now();
// Fraction restante, de 1 (plein) à 0 (vide) — informatif, jamais anxiogène.
let fraction = $derived(Math.max(0, 1 - ecoule / dureeMs));
let termine = $derived(ecoule >= dureeMs);
const R = 15.5;
const C = 2 * Math.PI * R;
/** Temps écoulé en ms (pour le bonus côté /api/guess). */
export function tempsMs(): number {
return Date.now() - depart;
}
onMount(() => {
if (actif) {
intervalle = setInterval(() => {
ecoule = Date.now() - depart;
if (ecoule >= dureeMs && intervalle) {
clearInterval(intervalle);
intervalle = null;
}
}, 250);
}
return () => {
if (intervalle) clearInterval(intervalle);
};
});
</script>
<div class="timer" class:termine role="timer" aria-label="Minuteur" title="Minuteur">
<svg viewBox="0 0 36 36" aria-hidden="true">
<circle class="fond" cx="18" cy="18" r={R} />
<circle
class="reste"
cx="18"
cy="18"
r={R}
stroke-dasharray={C}
stroke-dashoffset={C * (1 - fraction)}
transform="rotate(-90 18 18)"
/>
</svg>
</div>
<style>
.timer {
width: 40px;
height: 40px;
}
svg {
width: 100%;
height: 100%;
display: block;
}
.fond {
fill: rgba(255, 253, 248, 0.85);
stroke: var(--oki-sable-fonce);
stroke-width: 3;
}
.reste {
fill: none;
stroke: var(--oki-vert-clair); /* couleur douce, calm technology */
stroke-width: 3;
stroke-linecap: round;
transition: stroke-dashoffset 0.25s linear;
}
.termine .reste {
stroke: var(--oki-sable-fonce);
}
</style>
+182
View File
@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from 'svelte';
import { t } from '$lib/i18n/store.svelte';
import type { WikiSummary } from '$lib/types';
interface Props {
nom: string;
wikidataId: string | null;
tonPositif?: string;
tonNeutre?: string;
}
let { nom, wikidataId, tonPositif, tonNeutre }: Props = $props();
// undefined = chargement, null = pas de fiche (Cas B), WikiSummary = Cas A
let wiki = $state<WikiSummary | null | undefined>(undefined);
onMount(() => {
if (!wikidataId) {
wiki = null;
return;
}
let annule = false;
void (async () => {
try {
const res = await fetch(`/api/wiki/${wikidataId}`);
if (annule) return;
wiki = res.ok ? ((await res.json()) as WikiSummary) : null;
} catch {
if (!annule) wiki = null;
}
})();
return () => {
annule = true;
};
});
const lienContribution = $derived(
`https://fr.wikipedia.org/w/index.php?search=${encodeURIComponent(nom)}&title=Sp%C3%A9cial:Recherche`
);
</script>
{#if wiki === undefined}
<div class="panneau chargement">
<p>{t().explorer.chargerFiche}</p>
</div>
{:else if wiki}
<!-- Cas A : fiche Wikipédia existante -->
<article class="panneau cas-a">
<h2>
<a href={wiki.url} target="_blank" rel="noopener noreferrer">{wiki.title}</a>
</h2>
{#if tonPositif || tonNeutre}
<p class="ton">{tonPositif ?? tonNeutre}</p>
{/if}
{#if wiki.thumbnail}
<img src={wiki.thumbnail} alt="" loading="lazy" decoding="async" />
{/if}
<p class="extrait">{wiki.extract}</p>
<a class="lien-wiki" href={wiki.url} target="_blank" rel="noopener noreferrer">
{t().resultat.lireWiki}
</a>
</article>
{:else}
<!-- Cas B : lacune documentaire — panneau volontairement distinct (Peak-End) -->
<article class="panneau cas-b">
<h2><span class="icone" aria-hidden="true"></span> {t().lacune.titre}</h2>
<p class="texte">{t().lacune.texte}</p>
<a class="btn btn-cta" href={lienContribution} target="_blank" rel="noopener noreferrer">
{t().lacune.cta}
</a>
<p class="guide">
{t().lacune.guide}
<a href="https://fr.wikipedia.org/wiki/Aide:Premiers_pas" target="_blank" rel="noopener noreferrer">
{t().lacune.guideLien}
</a>
</p>
</article>
{/if}
<style>
.panneau {
border-radius: var(--oki-radius);
padding: 1.1rem 1.25rem;
box-shadow: var(--oki-ombre);
}
.chargement {
background: var(--oki-blanc);
color: var(--oki-encre-doux);
text-align: center;
}
/* Cas A : vert doux, apaisé */
.cas-a {
background: var(--oki-vert-doux);
border: 1.5px solid var(--oki-vert-clair);
}
.cas-a h2 {
margin: 0 0 0.3rem;
font-size: 1.2rem;
}
.cas-a h2 a {
color: var(--oki-vert);
}
.cas-a .ton {
margin: 0 0 0.6rem;
font-weight: 600;
color: var(--oki-vert-clair);
}
.cas-a img {
float: right;
width: 96px;
height: auto;
margin: 0 0 0.5rem 0.8rem;
border-radius: var(--oki-radius-petit);
}
.cas-a .extrait {
margin: 0 0 0.6rem;
}
.lien-wiki {
color: var(--oki-bleu);
font-weight: 650;
}
/* Cas B : teinte sable/orangée distincte, icône spécifique */
.cas-b {
background: var(--oki-lacune-fond);
border: 2px solid var(--oki-lacune-bord);
}
.cas-b h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
color: #8a5a00;
display: flex;
align-items: center;
gap: 0.5rem;
}
.cas-b .icone {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
border: 2.5px dashed var(--oki-lacune-bord);
border-radius: 50%;
font-size: 1rem;
}
.cas-b .texte {
margin: 0 0 0.9rem;
font-size: 1.02rem;
}
.btn-cta {
background: var(--oki-lacune-bord);
color: #fff;
}
.btn-cta:hover {
background: #b98a2f;
}
.guide {
margin: 0.8rem 0 0;
font-size: 0.88rem;
color: var(--oki-encre-doux);
}
.guide a {
color: var(--oki-bleu);
font-weight: 600;
}
</style>
+109
View File
@@ -0,0 +1,109 @@
const fr = {
app: {
titre: 'JWE',
sousTitre: 'Jeu de géolocalisation — Guadeloupe, Martinique, Guyane, La Réunion'
},
regions: {
GUADELOUPE: 'Guadeloupe',
MARTINIQUE: 'Martinique',
GUYANE: 'Guyane',
REUNION: 'La Réunion',
TOUTES: 'Toutes les régions'
},
modes: {
monument: 'Konnèt moniman',
monumentDesc: 'Reconnais le monument photographié et place-le sur la carte.',
lieu: 'Kote mwen ye ?',
lieuDesc: 'Une rue, un bord de mer, une forêt… devine où tu es.',
defi: 'Défi 5 rounds',
defiDesc: '5 lieux daffilée, monuments et lieux mélangés.',
explorer: 'Aprann',
explorerDesc: 'Explore la carte librement et lis les fiches Wikipédia. Sans score.'
},
accueil: {
choixRegion: 'Choisis ta région',
choixMode: 'Choisis ton mode',
options: 'Options',
timer: 'Minuteur (60 s, bonus de score)',
jouer: 'Jouer'
},
jeu: {
charger: 'Chargement du lieu…',
erreur: 'Impossible de charger un lieu. Réessaie.',
reessayer: 'Réessayer',
valider: 'Valider ma position',
passer: 'Passer',
indice: 'Indice',
indicePenalite: '10 % de score',
indiceRestant: 'indice restant',
indicesRestants: 'indices restants',
plusDIndice: 'Plus dindice disponible',
poserMarqueur: 'Clique sur la carte pour placer ton marqueur',
round: 'Manche',
sur: 'sur',
difficulte: {
FACILE: 'Facile',
MOYEN: 'Moyen',
DIFFICILE: 'Difficile',
EXPERT: 'Expert'
}
},
resultat: {
bravo: 'Bravo !',
score: 'Score',
points: 'points',
distance: 'Distance',
km: 'km',
trouve: 'Tu as trouvé ! Apprends-en plus sur ce lieu.',
aDecouvrir: 'Le lieu était ici. Apprends-en plus sur lui.',
lireWiki: 'Lire la suite sur Wikipédia',
commune: 'Commune',
region: 'Région',
suivant: 'Lieu suivant',
voirBilan: 'Voir le bilan',
rejouer: 'Rejouer',
retourAccueil: 'Retour à laccueil'
},
lacune: {
titre: 'Pwen blindé — Sa pa dokimanté',
texte:
'Ce lieu existe, mais il na pas de page sur Wikipédia ni de fiche sur Wikidata. Pour nos régions, cest un enjeu majeur : si nous ne documentons pas nos propres territoires, notre histoire et notre géographie disparaissent des cartes numériques et des bases de données mondiales (qui alimentent même lintelligence artificielle).',
cta: 'Kontribyé sou Wikipédia',
guide: 'Besoin daide pour démarrer ? Suis le guide OKI « Comment contribuer à Wikipédia ».',
guideLien: 'Ouvrir le guide de contribution'
},
defi: {
bilan: 'Bilan du défi',
scoreTotal: 'Score total',
partager: 'Partager',
telecharger: 'Télécharger limage',
taPosition: 'Ta position',
positionReelle: 'Position réelle',
manche: 'Manche'
},
explorer: {
titre: 'Aprann — Explorer',
filtrer: 'Filtrer par région',
chargerFiche: 'Chargement de la fiche…',
aucuneFiche: 'Sélectionne un marqueur sur la carte.',
fermer: 'Fermer le panneau'
},
carte: {
ariaLabel: 'Carte de jeu. Flèches pour déplacer le marqueur, Entrée pour valider.',
sautRegion: 'Aller à la région',
zoomIn: 'Zoomer',
zoomOut: 'Dézoomer'
},
micro: {
bravo: 'Bravo !',
kiteSaYe: 'Kité sa ye ?',
pwenBlinde: 'Pwen blindé'
},
langue: {
fr: 'Français',
gcf: 'Kreyòl'
}
};
export default fr;
export type Dict = typeof fr;
+110
View File
@@ -0,0 +1,110 @@
import type { Dict } from './fr';
const gcf: Dict = {
app: {
titre: 'JWE',
sousTitre: 'Jwé jéyolokalizasyon — Gwadloup, Matinik, Giyan, Léryinyon'
},
regions: {
GUADELOUPE: 'Gwadloup',
MARTINIQUE: 'Matinik',
GUYANE: 'Giyan',
REUNION: 'Léryinyon',
TOUTES: 'Tout péyi yo'
},
modes: {
monument: 'Konnèt moniman',
monumentDesc: 'Roukonèt moniman-an asou foto-a é mété’y asou kat-la.',
lieu: 'Kote mwen ye ?',
lieuDesc: 'On lari, on bodlanmè, on fowé… chwé kote ou yé.',
defi: 'Défi 5 rounds',
defiDesc: '5 koté youn dèyè ròt, moniman épi plas mélanjé.',
explorer: 'Aprann',
explorerDesc: 'Flanné asou kat-la é li fich Wikipédia yo. San pwen.'
},
accueil: {
choixRegion: 'Chwazi péyi ou',
choixMode: 'Chwazi jan ou vlé jwé',
options: 'Règlaj',
timer: 'Kronomèt (60 s, bonus pwen)',
jouer: 'Jwé'
},
jeu: {
charger: 'Nou ka chajé koté-la…',
erreur: 'Nou pa rivé chajé on koté. Éséyé ankò.',
reessayer: 'Éséyé ankò',
valider: 'Validé pozisyon mwen',
passer: 'Pasé',
indice: 'Endis',
indicePenalite: '10 % pwen',
indiceRestant: 'endis ki rété',
indicesRestants: 'endis ki rété',
plusDIndice: 'Pa ni endis ankò',
poserMarqueur: 'Kwiké asou kat-la pou mété makò-a',
round: 'Mannèk',
sur: 'asou',
difficulte: {
FACILE: 'Fasil',
MOYEN: 'Mwayen',
DIFFICILE: 'Difisil',
EXPERT: 'Espè'
}
},
resultat: {
bravo: 'Bravo !',
score: 'Pwen',
points: 'pwen',
distance: 'Distans',
km: 'km',
trouve: 'Ou trouvé ! Aprann pli plis asou koté sa-a.',
aDecouvrir: 'Koté-la té la. Aprann pli plis asouy.',
lireWiki: 'Li swit-la asou Wikipédia',
commune: 'Komin',
region: 'Péyi',
suivant: 'Koté swivan',
voirBilan: 'Wè bilan-an',
rejouer: 'Rèjwé',
retourAccueil: 'Tounen an lakay'
},
lacune: {
titre: 'Pwen blindé — Sa pa dokimanté',
texte:
'Koté sa-a égzisté, men i pa ni on paj asou Wikipédia ni on fich asou Wikidata. Pou péyi nou, sé on zafè enpòtan anpil : si nou pa dokimanté pwòp téritwa nou, listwa épi jéwografi nou kay disparèt asou kat nimérik épi labaz donné lemonn yo (ki manm ka bay manjé a lintelijans atifisyèl).',
cta: 'Kontribyé sou Wikipédia',
guide: 'Ou bizwen èd pou koumansé ? Swiv gid OKI « Kijan pou kontribyé sou Wikipédia ».',
guideLien: 'Ouvri gid kontribisyon-an'
},
defi: {
bilan: 'Bilan défi-a',
scoreTotal: 'Total pwen',
partager: 'Palé di moun',
telecharger: 'Téléchajé foto-a',
taPosition: 'Pozisyon ou',
positionReelle: 'Rèl pozisyon-an',
manche: 'Mannèk'
},
explorer: {
titre: 'Aprann — Flanné',
filtrer: 'Triyé pa péyi',
chargerFiche: 'Nou ka chajé fich-la…',
aucuneFiche: 'Kwiké asou on makò asou kat-la.',
fermer: 'Fèmé pano-a'
},
carte: {
ariaLabel: 'Kat jwé-a. Flèch yo pou déplasé makò-a, Antré pou validé.',
sautRegion: 'Alé o péyi',
zoomIn: 'Zoumé',
zoomOut: 'Dézoumé'
},
micro: {
bravo: 'Bravo !',
kiteSaYe: 'Kité sa ye ?',
pwenBlinde: 'Pwen blindé'
},
langue: {
fr: 'Français',
gcf: 'Kreyòl'
}
};
export default gcf;
+38
View File
@@ -0,0 +1,38 @@
import { browser } from '$app/environment';
import fr, { type Dict } from './fr';
import gcf from './gcf';
export type Lang = 'fr' | 'gcf';
const dicts: Record<Lang, Dict> = { fr, gcf };
const STORAGE_KEY = 'jwe-lang';
let lang = $state<Lang>('fr');
/** Dictionnaire courant — fonction réactive : lit le `$state`, donc les templates se mettent à jour. */
export function t(): Dict {
return dicts[lang];
}
export function getLang(): Lang {
return lang;
}
export function setLang(next: Lang): void {
lang = next;
if (browser) {
localStorage.setItem(STORAGE_KEY, next);
document.documentElement.lang = next;
}
}
/** À appeler une fois au montage du layout : restaure la langue persistée. */
export function initLang(): void {
if (!browser) return;
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'fr' || saved === 'gcf') {
setLang(saved);
} else {
document.documentElement.lang = lang;
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { Region } from '$lib/types';
/** Bornes de chaque région : [lonOuest, latSud, lonEst, latNord]. */
export const REGION_BOUNDS: Record<Region, [number, number, number, number]> = {
GUADELOUPE: [-61.9, 15.8, -60.95, 16.55],
MARTINIQUE: [-61.25, 14.35, -60.75, 14.95],
GUYANE: [-54.7, 1.9, -51.5, 6.0],
REUNION: [55.15, -21.42, 55.9, -20.82]
};
/** Enveloppe union des 4 régions (≈ lon 63…57, lat 23…18) : interdit de sortir du terrain de jeu. */
export const MAX_BOUNDS: [number, number, number, number] = [-63, -23, 57, 18];
export const MIN_ZOOM = 4;
export const REGIONS: Region[] = ['GUADELOUPE', 'MARTINIQUE', 'GUYANE', 'REUNION'];
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
import rawLieux from './data/lieux.json';
import type {
Categorie,
Difficulte,
ExplorerLieu,
GuessResult,
Lieu,
Region,
RoundPlace
} from '$lib/types';
const lieux = rawLieux as Lieu[];
const REGIONS: Region[] = ['GUADELOUPE', 'MARTINIQUE', 'GUYANE', 'REUNION'];
const CATEGORIES: Categorie[] = ['MONUMENT', 'LIEU'];
export const MAX_POINTS = 5000;
/** Rayon (km) de la courbe de score selon la difficulté du lieu. */
export const RAYONS: Record<Difficulte, number> = {
FACILE: 50,
MOYEN: 15,
DIFFICILE: 5,
EXPERT: 1
};
/** Distance haversine en km. */
export function haversineKm(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number {
const R = 6371;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLon = ((b.lon - a.lon) * Math.PI) / 180;
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(s));
}
function parseRegion(value: string | null): Region | null {
return value && REGIONS.includes(value as Region) ? (value as Region) : null;
}
function parseCategorie(value: string | null): Categorie | null {
return value && CATEGORIES.includes(value as Categorie) ? (value as Categorie) : null;
}
export function getLieuById(id: string): Lieu | undefined {
return lieux.find((l) => l.id === id);
}
/** Sélection aléatoire d'un lieu, avec filtres optionnels. */
export function getRandomLieu(options: {
region?: string | null;
categorie?: string | null;
exclude?: string | null;
}): Lieu | null {
const region = parseRegion(options.region ?? null);
const categorie = parseCategorie(options.categorie ?? null);
const excluded = new Set(
(options.exclude ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
);
let pool = lieux.filter((l) => !excluded.has(l.id));
if (region) pool = pool.filter((l) => l.region === region);
if (categorie) pool = pool.filter((l) => l.categorie === categorie);
// Si tout a été exclu, on réessaie sans les exclusions (le jeu doit continuer).
if (pool.length === 0) {
pool = lieux;
if (region) pool = pool.filter((l) => l.region === region);
if (categorie) pool = pool.filter((l) => l.categorie === categorie);
}
if (pool.length === 0) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
/** Anti-triche : ne garde que les champs publiques pour /api/round. */
export function sanitizeRound(lieu: Lieu): RoundPlace {
return {
id: lieu.id,
nom: lieu.nom,
nom_creole: lieu.nom_creole,
categorie: lieu.categorie,
difficulte: lieu.difficulte,
photo: lieu.photo,
indices: lieu.indices
};
}
/**
* Scoring recalculé côté serveur :
* score = round(5000 × e^(d/r)), r selon difficulté ;
* pénalité 10 % par indice ; bonus temps ≤ +20 % ; total plafonné à 5000.
*/
export function scoreGuess(
lieu: Lieu,
guess: { lat: number; lon: number },
hintsUsed: number,
timeMs?: number
): GuessResult {
const distanceKm = haversineKm(guess, lieu.coordonnees);
const base = Math.round(MAX_POINTS * Math.exp(-distanceKm / RAYONS[lieu.difficulte]));
const penalises = base * Math.max(0, 1 - 0.1 * Math.max(0, hintsUsed));
let total = penalises;
if (typeof timeMs === 'number' && Number.isFinite(timeMs) && timeMs >= 0) {
total += penalises * 0.2 * Math.max(0, 1 - timeMs / 60000);
}
const score = Math.min(MAX_POINTS, Math.round(total));
return {
distanceKm: Math.round(distanceKm * 10) / 10,
score,
coordonnees: lieu.coordonnees,
commune: lieu.commune,
region: lieu.region,
nom: lieu.nom,
nom_creole: lieu.nom_creole,
wikidata_id: lieu.wikidata_id
};
}
/** Réponse « Passer » : la vérité est révélée, score 0, pas de distance. */
export function skipResult(lieu: Lieu): GuessResult {
return {
distanceKm: null,
score: 0,
coordonnees: lieu.coordonnees,
commune: lieu.commune,
region: lieu.region,
nom: lieu.nom,
nom_creole: lieu.nom_creole,
wikidata_id: lieu.wikidata_id
};
}
/** Catalogue Explorer : coords arrondies à 0.05° (mode sans score, fiches autorisées). */
export function catalogueExplorer(): ExplorerLieu[] {
return lieux.map((l) => ({
id: l.id,
nom: l.nom,
nom_creole: l.nom_creole,
region: l.region,
commune: l.commune,
coordonnees: {
lat: Math.round(l.coordonnees.lat / 0.05) * 0.05,
lon: Math.round(l.coordonnees.lon / 0.05) * 0.05
},
categorie: l.categorie,
difficulte: l.difficulte,
wikidata_id: l.wikidata_id,
photo: l.photo
}));
}
+32
View File
@@ -0,0 +1,32 @@
import type { Coordonnees, GuessResult, RoundPlace } from '$lib/types';
export interface MancheDefi {
place: RoundPlace;
guess: Coordonnees | null;
result: GuessResult;
}
/** État du Défi 5 rounds — client uniquement, sans session serveur. */
function creerDefiStore() {
let manches = $state<MancheDefi[]>([]);
return {
get manches() {
return manches;
},
get total() {
return manches.reduce((s, m) => s + m.result.score, 0);
},
get termine() {
return manches.length >= 5;
},
ajouter(manche: MancheDefi) {
manches = [...manches, manche];
},
reinitialiser() {
manches = [];
}
};
}
export const defi = creerDefiStore();
+118
View File
@@ -0,0 +1,118 @@
/* Tokens OKI — doctrine « calm technology » : couleurs ancrées dans le territoire,
un seul accent fort (corail) réservé à l'action primaire (Von Restorff). */
:root {
--oki-vert: #0b3d2e; /* vert profond — couleur principale de la marque */
--oki-vert-clair: #17624a;
--oki-vert-doux: #e3efe7;
--oki-sable: #f5efe2; /* fonds */
--oki-sable-fonce: #e7dcc4;
--oki-bleu: #175e70; /* bleu caraïbe profond */
--oki-bleu-doux: #d9ecef;
--oki-corail: #e4572e; /* accent — action primaire UNIQUEMENT */
--oki-corail-fonce: #c4441f;
--oki-encre: #22302a;
--oki-encre-doux: #5a6b62;
--oki-blanc: #fffdf8;
--oki-lacune-fond: #fdf3e3; /* panneau Cas B « Pwen blindé » — teinte distincte */
--oki-lacune-bord: #d9a441;
--oki-radius: 14px;
--oki-radius-petit: 9px;
--oki-ombre: 0 2px 10px rgba(11, 61, 46, 0.14);
--oki-font:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
font-family: var(--oki-font);
color: var(--oki-encre);
background: var(--oki-sable);
line-height: 1.5;
}
button {
font-family: inherit;
}
/* — Boutons — */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: none;
border-radius: var(--oki-radius-petit);
padding: 0.85rem 1.6rem;
font-size: 1.05rem;
font-weight: 650;
cursor: pointer;
text-decoration: none;
transition: background-color 0.15s ease;
}
.btn:focus-visible,
a:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid var(--oki-bleu);
outline-offset: 2px;
}
/* Action primaire : le seul élément fortement coloré de l'écran */
.btn-primaire {
background: var(--oki-corail);
color: #fff;
box-shadow: var(--oki-ombre);
}
.btn-primaire:hover {
background: var(--oki-corail-fonce);
}
.btn-primaire:disabled {
background: var(--oki-sable-fonce);
color: var(--oki-encre-doux);
cursor: not-allowed;
box-shadow: none;
}
.btn-secondaire {
background: var(--oki-vert-doux);
color: var(--oki-vert);
}
.btn-secondaire:hover {
background: #d2e5d9;
}
.btn-tertiaire {
background: transparent;
color: var(--oki-vert-clair);
border: 1.5px solid var(--oki-vert-clair);
}
.btn-tertiaire:hover {
background: var(--oki-vert-doux);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
+82
View File
@@ -0,0 +1,82 @@
export type Region = 'GUADELOUPE' | 'MARTINIQUE' | 'GUYANE' | 'REUNION';
export type Categorie = 'MONUMENT' | 'LIEU';
export type Difficulte = 'FACILE' | 'MOYEN' | 'DIFFICILE' | 'EXPERT';
export interface Coordonnees {
lat: number;
lon: number;
}
export interface Indice {
niveau: number;
texte: string;
}
export interface Photo {
url: string;
credit: string;
alt: string;
}
/** Lieu complet — serveur uniquement, jamais exposé tel quel au client. */
export interface Lieu {
id: string;
nom: string;
nom_creole: string;
region: Region;
commune: string;
coordonnees: Coordonnees;
categorie: Categorie;
difficulte: Difficulte;
wikidata_id: string | null;
photo: Photo;
indices: Indice[];
}
/** Lieu SANITISÉ envoyé par GET /api/round (aucune coordonnée, commune, région, wikidata_id). */
export interface RoundPlace {
id: string;
nom: string;
nom_creole: string;
categorie: Categorie;
difficulte: Difficulte;
photo: Photo;
indices: Indice[];
}
/** Réponse de POST /api/guess : la vérité n'est révélée qu'après soumission. */
export interface GuessResult {
distanceKm: number | null;
score: number;
coordonnees: Coordonnees;
commune: string;
region: Region;
nom: string;
nom_creole: string;
wikidata_id: string | null;
}
/** Entrée du catalogue Explorer : coords arrondies à 0.05°, mode sans score. */
export interface ExplorerLieu {
id: string;
nom: string;
nom_creole: string;
region: Region;
commune: string;
coordonnees: Coordonnees;
categorie: Categorie;
difficulte: Difficulte;
wikidata_id: string | null;
photo: Photo;
}
/** Réponse normalisée de GET /api/wiki/[qid]. */
export interface WikiSummary {
qid: string;
title: string;
extract: string;
url: string;
thumbnail: string | null;
}
export type GameMode = 'monument' | 'lieu' | 'defi';
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
import { onMount } from 'svelte';
import '$lib/styles/oki.css';
import LangSwitch from '$lib/components/LangSwitch.svelte';
import { initLang, t } from '$lib/i18n/store.svelte';
let { children } = $props();
onMount(() => {
initLang();
});
</script>
<div class="app">
<header class="entete">
<a href="/" class="logo" aria-label="JWE — accueil">
<svg viewBox="0 0 32 32" width="30" height="30" aria-hidden="true">
<circle cx="16" cy="16" r="14" fill="none" stroke="currentColor" stroke-width="2.5" />
<path d="M16 7 L21 22 L16 18.5 L11 22 Z" fill="currentColor" />
</svg>
<span>{t().app.titre}</span>
</a>
<nav class="nav-lang">
<a href="/explorer" class="lien-explorer">{t().modes.explorer}</a>
<LangSwitch />
</nav>
</header>
<main>
{@render children()}
</main>
</div>
<style>
.app {
min-height: 100dvh;
display: flex;
flex-direction: column;
}
.entete {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1rem;
background: var(--oki-vert);
color: #fff;
}
.logo {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #fff;
text-decoration: none;
font-size: 1.4rem;
font-weight: 800;
letter-spacing: 0.04em;
}
.nav-lang {
display: flex;
align-items: center;
gap: 0.8rem;
}
.lien-explorer {
color: var(--oki-vert-doux);
text-decoration: none;
font-weight: 600;
font-size: 0.95rem;
}
.lien-explorer:hover {
color: #fff;
}
main {
flex: 1;
display: flex;
flex-direction: column;
}
</style>
+258
View File
@@ -0,0 +1,258 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { t } from '$lib/i18n/store.svelte';
import { REGIONS } from '$lib/map/regions';
import type { GameMode, Region } from '$lib/types';
type ChoixRegion = Region | 'TOUTES';
let region = $state<ChoixRegion>('TOUTES');
let mode = $state<GameMode>('monument');
let timer = $state(false);
const modes: { id: GameMode; emoji: string }[] = [
{ id: 'monument', emoji: '🏛️' },
{ id: 'lieu', emoji: '📍' },
{ id: 'defi', emoji: '⛰️' }
];
function jouer(): void {
const params = new URLSearchParams();
if (region !== 'TOUTES') params.set('region', region);
if (timer) params.set('timer', '1');
goto(`/jeu/${mode}${params.size ? `?${params}` : ''}`);
}
</script>
<svelte:head>
<title>{t().app.titre}{t().app.sousTitre}</title>
<meta name="description" content={t().app.sousTitre} />
</svelte:head>
<div class="accueil">
<section class="hero">
<h1>{t().app.titre}</h1>
<p class="accroche">{t().micro.kiteSaYe}</p>
<p class="sous-titre">{t().app.sousTitre}</p>
</section>
<section aria-labelledby="titre-regions">
<h2 id="titre-regions">{t().accueil.choixRegion}</h2>
<div class="grille regions">
{#each REGIONS as r (r)}
<button
type="button"
class="carte-region region-{r.toLowerCase()}"
class:choisi={region === r}
aria-pressed={region === r}
onclick={() => (region = r)}
>
<span class="nom">{t().regions[r]}</span>
</button>
{/each}
<button
type="button"
class="carte-region toutes"
class:choisi={region === 'TOUTES'}
aria-pressed={region === 'TOUTES'}
onclick={() => (region = 'TOUTES')}
>
<span class="nom">{t().regions.TOUTES}</span>
</button>
</div>
</section>
<section aria-labelledby="titre-modes">
<h2 id="titre-modes">{t().accueil.choixMode}</h2>
<div class="grille modes">
{#each modes as m (m.id)}
<button
type="button"
class="carte-mode"
class:choisi={mode === m.id}
aria-pressed={mode === m.id}
onclick={() => (mode = m.id)}
>
<span class="emoji" aria-hidden="true">{m.emoji}</span>
<span class="nom">{t().modes[m.id]}</span>
<span class="desc">{t().modes[`${m.id}Desc`]}</span>
</button>
{/each}
<a href="/explorer" class="carte-mode explorer">
<span class="emoji" aria-hidden="true">🧭</span>
<span class="nom">{t().modes.explorer}</span>
<span class="desc">{t().modes.explorerDesc}</span>
</a>
</div>
</section>
<section class="options" aria-labelledby="titre-options">
<h2 id="titre-options">{t().accueil.options}</h2>
<label class="option-timer">
<input type="checkbox" bind:checked={timer} />
<span>{t().accueil.timer}</span>
</label>
</section>
<div class="zone-pouce">
<button type="button" class="btn btn-primaire jouer" onclick={jouer}>
{t().accueil.jouer}
</button>
</div>
</div>
<style>
.accueil {
width: 100%;
max-width: 860px;
margin: 0 auto;
padding: 1.25rem 1rem 7rem; /* espace pour le bouton fixe en zone pouce */
display: flex;
flex-direction: column;
gap: 1.6rem;
}
.hero {
text-align: center;
}
.hero h1 {
margin: 0;
font-size: 3rem;
font-weight: 800;
color: var(--oki-vert);
letter-spacing: 0.06em;
}
.accroche {
margin: 0.2rem 0 0;
font-size: 1.25rem;
font-weight: 650;
color: var(--oki-vert-clair);
font-style: italic;
}
.sous-titre {
margin: 0.4rem 0 0;
color: var(--oki-encre-doux);
}
h2 {
font-size: 1.05rem;
color: var(--oki-vert);
margin: 0 0 0.6rem;
}
.grille {
display: grid;
gap: 0.7rem;
}
.regions {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.modes {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.carte-region,
.carte-mode {
border: 2px solid transparent;
border-radius: var(--oki-radius);
background: var(--oki-blanc);
box-shadow: var(--oki-ombre);
cursor: pointer;
text-align: center;
color: var(--oki-encre);
text-decoration: none;
}
.carte-region {
padding: 1.4rem 0.6rem;
font-size: 1.05rem;
font-weight: 700;
}
.carte-region .nom {
color: var(--oki-vert);
}
.carte-mode {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
padding: 1rem 0.8rem;
}
.carte-mode .emoji {
font-size: 1.6rem;
}
.carte-mode .nom {
font-weight: 750;
color: var(--oki-vert);
}
.carte-mode .desc {
font-size: 0.85rem;
color: var(--oki-encre-doux);
}
.carte-region:hover,
.carte-mode:hover {
border-color: var(--oki-vert-clair);
}
.carte-region.choisi,
.carte-mode.choisi {
border-color: var(--oki-vert);
background: var(--oki-vert-doux);
}
.options {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.option-timer {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 1rem;
cursor: pointer;
}
.option-timer input {
width: 1.2rem;
height: 1.2rem;
accent-color: var(--oki-vert);
}
/* Bouton « Jouer » : fixe en zone du pouce sur mobile */
.zone-pouce {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 0.8rem 1rem calc(0.8rem + env(safe-area-inset-bottom));
background: linear-gradient(transparent, var(--oki-sable) 35%);
display: flex;
justify-content: center;
z-index: 10;
}
.jouer {
width: min(420px, 100%);
padding: 1rem;
font-size: 1.2rem;
}
@media (min-width: 768px) {
.hero h1 {
font-size: 3.6rem;
}
}
</style>
+51
View File
@@ -0,0 +1,51 @@
import { json } from '@sveltejs/kit';
import { getLieuById, scoreGuess, skipResult } from '$lib/server/lieux';
import type { RequestHandler } from './$types';
interface GuessBody {
id?: unknown;
lat?: unknown;
lon?: unknown;
hintsUsed?: unknown;
timeMs?: unknown;
skip?: unknown;
}
/**
* POST /api/guess { id, lat, lon, hintsUsed, timeMs? }
* → score recalculé serveur + vérité du lieu (révélée seulement ici).
* `skip: true` révèle la réponse avec un score de 0 (bouton « Passer »).
*/
export const POST: RequestHandler = async ({ request }) => {
let body: GuessBody;
try {
body = (await request.json()) as GuessBody;
} catch {
return json({ erreur: 'Corps JSON invalide' }, { status: 400 });
}
if (typeof body.id !== 'string') {
return json({ erreur: 'id manquant' }, { status: 400 });
}
const lieu = getLieuById(body.id);
if (!lieu) {
return json({ erreur: 'Lieu inconnu' }, { status: 404 });
}
if (body.skip === true) {
return json(skipResult(lieu));
}
const lat = Number(body.lat);
const lon = Number(body.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) {
return json({ erreur: 'Coordonnées invalides' }, { status: 400 });
}
const hintsUsed = Math.max(0, Math.floor(Number(body.hintsUsed) || 0));
const timeMs =
typeof body.timeMs === 'number' && Number.isFinite(body.timeMs) && body.timeMs >= 0
? body.timeMs
: undefined;
return json(scoreGuess(lieu, { lat, lon }, hintsUsed, timeMs));
};
+10
View File
@@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import { catalogueExplorer } from '$lib/server/lieux';
import type { RequestHandler } from './$types';
/** GET /api/lieux → catalogue Explorer (coords arrondies à 0.05°, mode sans score). */
export const GET: RequestHandler = () => {
return json(catalogueExplorer(), {
headers: { 'Cache-Control': 'public, max-age=300' }
});
};
+18
View File
@@ -0,0 +1,18 @@
import { json } from '@sveltejs/kit';
import { getRandomLieu, sanitizeRound } from '$lib/server/lieux';
import type { RequestHandler } from './$types';
/** GET /api/round?region=&categorie=&exclude=id1,id2 → lieu aléatoire SANITISÉ. */
export const GET: RequestHandler = ({ url }) => {
const lieu = getRandomLieu({
region: url.searchParams.get('region'),
categorie: url.searchParams.get('categorie'),
exclude: url.searchParams.get('exclude')
});
if (!lieu) {
return json({ erreur: 'Aucun lieu disponible pour ces critères' }, { status: 404 });
}
return json(sanitizeRound(lieu), {
headers: { 'Cache-Control': 'no-store' }
});
};
+81
View File
@@ -0,0 +1,81 @@
import { json } from '@sveltejs/kit';
import type { WikiSummary } from '$lib/types';
import type { RequestHandler } from './$types';
const USER_AGENT = 'JWE-OKI/2.0 (https://labola.o-k-i.net/ORGANISATION-KA-INTERNATIONALE/JWE; contact OKI)';
const TTL_MS = 24 * 60 * 60 * 1000; // 24 h
const cache = new Map<string, { expires: number; summary: WikiSummary }>();
interface WbEntity {
labels?: { fr?: { value: string } };
sitelinks?: { frwiki?: { title: string } };
}
interface WbResponse {
entities?: Record<string, WbEntity>;
}
interface RestSummary {
title?: string;
extract?: string;
content_urls?: { desktop?: { page?: string } };
thumbnail?: { source?: string };
}
/** GET /api/wiki/[qid] → extrait Wikipédia FR normalisé (404 si pas de sitelink fr). */
export const GET: RequestHandler = async ({ params, fetch }) => {
const qid = params.qid;
if (!/^Q\d+$/.test(qid)) {
return json({ erreur: 'Identifiant Wikidata invalide' }, { status: 400 });
}
const cached = cache.get(qid);
if (cached && cached.expires > Date.now()) {
return json(cached.summary, { headers: { 'Cache-Control': 'public, max-age=3600' } });
}
const headers = { 'User-Agent': USER_AGENT, Accept: 'application/json' };
// 1. Wikidata : sitelink frwiki (+ libellé fr en fallback)
const wbUrl = `https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${encodeURIComponent(qid)}&props=sitelinks%7Clabels&sitefilter=frwiki&languages=fr&format=json`;
let entity: WbEntity | undefined;
try {
const res = await fetch(wbUrl, { headers });
if (!res.ok) throw new Error(`wikidata ${res.status}`);
entity = ((await res.json()) as WbResponse).entities?.[qid];
} catch {
return json({ erreur: 'Wikidata injoignable' }, { status: 502 });
}
const titre = entity?.sitelinks?.frwiki?.title;
if (!titre) {
// Pas de page Wikipédia FR → le front bascule sur l'écran « Pwen blindé ».
return json({ erreur: 'Pas de page Wikipédia en français' }, { status: 404 });
}
// 2. Wikipédia FR : résumé de l'article
let data: RestSummary;
try {
const res = await fetch(
`https://fr.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(titre)}`,
{ headers }
);
if (res.status === 404) return json({ erreur: 'Article introuvable' }, { status: 404 });
if (!res.ok) throw new Error(`wikipedia ${res.status}`);
data = (await res.json()) as RestSummary;
} catch {
return json({ erreur: 'Wikipédia injoignable' }, { status: 502 });
}
const summary: WikiSummary = {
qid,
title: data.title ?? titre,
extract: data.extract ?? '',
url: data.content_urls?.desktop?.page ?? `https://fr.wikipedia.org/wiki/${encodeURIComponent(titre)}`,
thumbnail: data.thumbnail?.source ?? null
};
cache.set(qid, { expires: Date.now() + TTL_MS, summary });
return json(summary, { headers: { 'Cache-Control': 'public, max-age=3600' } });
};
+261
View File
@@ -0,0 +1,261 @@
<script lang="ts">
import { onMount } from 'svelte';
import GameMap from '$lib/components/GameMap.svelte';
import WikiFiche from '$lib/components/WikiFiche.svelte';
import { t } from '$lib/i18n/store.svelte';
import { REGIONS } from '$lib/map/regions';
import type { ExplorerLieu, Region } from '$lib/types';
type Filtre = Region | 'TOUTES';
let lieux = $state<ExplorerLieu[]>([]);
let filtre = $state<Filtre>('TOUTES');
let selection = $state<ExplorerLieu | null>(null);
let erreur = $state(false);
const lieuxFiltres = $derived(
filtre === 'TOUTES' ? lieux : lieux.filter((l) => l.region === filtre)
);
const markers = $derived(
lieuxFiltres.map((l) => ({
id: l.id,
lat: l.coordonnees.lat,
lon: l.coordonnees.lon,
label: `${l.nom}${l.commune}`
}))
);
function ouvrir(id: string): void {
selection = lieux.find((l) => l.id === id) ?? null;
}
onMount(() => {
void (async () => {
try {
const res = await fetch('/api/lieux');
if (!res.ok) throw new Error(String(res.status));
lieux = (await res.json()) as ExplorerLieu[];
} catch {
erreur = true;
}
})();
});
</script>
<svelte:head>
<title>{t().app.titre}{t().explorer.titre}</title>
</svelte:head>
<div class="explorer">
<div class="filtres" role="group" aria-label={t().explorer.filtrer}>
<button
type="button"
class:actif={filtre === 'TOUTES'}
aria-pressed={filtre === 'TOUTES'}
onclick={() => (filtre = 'TOUTES')}
>
{t().regions.TOUTES}
</button>
{#each REGIONS as r (r)}
<button
type="button"
class:actif={filtre === r}
aria-pressed={filtre === r}
onclick={() => (filtre = r)}
>
{t().regions[r]}
</button>
{/each}
</div>
<div class="carte-explorer">
{#if erreur}
<p class="erreur">{t().jeu.erreur}</p>
{:else}
<GameMap interactive={false} {markers} onmarkerclick={ouvrir} />
{/if}
</div>
<!-- Panneau latéral (desktop) / bottom sheet (mobile) -->
<aside class="fiche" class:ouvert={!!selection} aria-live="polite">
{#if selection}
<button
type="button"
class="fermer"
aria-label={t().explorer.fermer}
onclick={() => (selection = null)}
>
</button>
<figure class="fiche-photo">
<img
src={selection.photo.url}
alt={selection.photo.alt}
loading="lazy"
decoding="async"
/>
<figcaption>{selection.photo.credit}</figcaption>
</figure>
<h1>{selection.nom}</h1>
{#if selection.nom_creole && selection.nom_creole !== selection.nom}
<p class="creole">{selection.nom_creole}</p>
{/if}
<p class="localisation">
{t().resultat.commune} : {selection.commune}{t().regions[selection.region]}
</p>
{#key selection.id}
<WikiFiche nom={selection.nom} wikidataId={selection.wikidata_id} />
{/key}
{:else}
<p class="vide">{t().explorer.aucuneFiche}</p>
{/if}
</aside>
</div>
<style>
.explorer {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
min-height: 0;
}
.filtres {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
padding: 0.6rem 0.8rem;
background: var(--oki-blanc);
border-bottom: 1px solid var(--oki-sable-fonce);
}
.filtres button {
border: 1.5px solid var(--oki-vert-clair);
background: transparent;
color: var(--oki-vert);
border-radius: 999px;
padding: 0.35rem 0.9rem;
font-size: 0.88rem;
font-weight: 650;
cursor: pointer;
}
.filtres button.actif {
background: var(--oki-vert);
border-color: var(--oki-vert);
color: #fff;
}
.carte-explorer {
flex: 1;
min-height: 0;
padding: 0.6rem;
}
.erreur {
text-align: center;
color: var(--oki-encre-doux);
}
/* Bottom sheet mobile */
.fiche {
position: absolute;
left: 0;
right: 0;
bottom: 0;
max-height: 62%;
overflow-y: auto;
background: var(--oki-blanc);
border-radius: var(--oki-radius) var(--oki-radius) 0 0;
box-shadow: 0 -4px 18px rgba(11, 61, 46, 0.2);
padding: 1rem;
transform: translateY(calc(100% - 3.2rem));
transition: transform 0.25s ease;
}
.fiche.ouvert {
transform: translateY(0);
}
.fiche .vide {
margin: 0;
text-align: center;
color: var(--oki-encre-doux);
padding-bottom: 0.4rem;
}
.fermer {
position: absolute;
top: 0.6rem;
right: 0.6rem;
border: none;
background: var(--oki-vert-doux);
color: var(--oki-vert);
width: 2rem;
height: 2rem;
border-radius: 50%;
font-size: 1rem;
cursor: pointer;
}
.fiche-photo {
margin: 0 0 0.6rem;
}
.fiche-photo img {
width: 100%;
max-height: 200px;
object-fit: cover;
border-radius: var(--oki-radius-petit);
display: block;
}
.fiche-photo figcaption {
font-size: 0.72rem;
color: var(--oki-encre-doux);
margin-top: 0.2rem;
}
.fiche h1 {
margin: 0;
font-size: 1.3rem;
color: var(--oki-vert);
}
.creole {
margin: 0.1rem 0 0;
font-style: italic;
color: var(--oki-vert-clair);
}
.localisation {
margin: 0.3rem 0 0.8rem;
color: var(--oki-encre-doux);
font-size: 0.92rem;
}
/* Panneau latéral desktop */
@media (min-width: 768px) {
.fiche {
left: auto;
top: 0;
right: 0;
bottom: 0;
width: 380px;
max-height: none;
border-radius: var(--oki-radius) 0 0 var(--oki-radius);
transform: translateX(calc(100% - 3.2rem));
}
.fiche.ouvert {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.fiche {
transition: none;
}
}
</style>
+543
View File
@@ -0,0 +1,543 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import GameMap from '$lib/components/GameMap.svelte';
import PhotoPanel from '$lib/components/PhotoPanel.svelte';
import ResultPanel from '$lib/components/ResultPanel.svelte';
import TimerRing from '$lib/components/TimerRing.svelte';
import { t } from '$lib/i18n/store.svelte';
import { defi } from '$lib/stores/defi.svelte';
import type { Coordonnees, GuessResult, RoundPlace } from '$lib/types';
let { data } = $props();
const mode = $derived(data.mode);
const regionParam = $derived(page.url.searchParams.get('region'));
const timerActif = $derived(page.url.searchParams.get('timer') === '1');
let place = $state<RoundPlace | null>(null);
let guess = $state<Coordonnees | null>(null);
let result = $state<GuessResult | null>(null);
let indicesMontres = $state<string[]>([]);
let chargement = $state(true);
let erreur = $state(false);
let enCours = $state(false); // requête guess en vol
let timerRef: ReturnType<typeof TimerRing> | undefined = $state();
let joues = $state<string[]>([]); // ids déjà joués (évite les répétitions)
let bilan = $state(false); // écran final du défi
const estDefi = $derived(mode === 'defi');
const mancheCourante = $derived(defi.manches.length + 1);
const defiTermine = $derived(estDefi && defi.termine);
const paires = $derived(
defi.manches
.filter((m) => m.guess !== null)
.map((m) => ({ from: m.guess!, to: m.result.coordonnees, label: m.result.nom }))
);
async function chargerRound(): Promise<void> {
chargement = true;
erreur = false;
place = null;
guess = null;
result = null;
indicesMontres = [];
try {
const params = new URLSearchParams();
if (regionParam) params.set('region', regionParam);
if (mode === 'monument') params.set('categorie', 'MONUMENT');
if (mode === 'lieu') params.set('categorie', 'LIEU');
if (joues.length > 0) params.set('exclude', joues.join(','));
const res = await fetch(`/api/round?${params}`);
if (!res.ok) throw new Error(String(res.status));
place = (await res.json()) as RoundPlace;
} catch {
erreur = true;
} finally {
chargement = false;
}
}
async function soumettre(skip: boolean): Promise<void> {
if (!place || result || enCours) return;
if (!skip && !guess) return;
enCours = true;
try {
const body: Record<string, unknown> = { id: place.id };
if (skip) {
body.skip = true;
} else {
body.lat = guess!.lat;
body.lon = guess!.lon;
body.hintsUsed = indicesMontres.length;
if (timerActif && timerRef) body.timeMs = timerRef.tempsMs();
}
const res = await fetch('/api/guess', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(String(res.status));
result = (await res.json()) as GuessResult;
joues = [...joues, place.id];
if (estDefi) {
defi.ajouter({ place, guess: skip ? null : guess, result });
}
} catch {
erreur = true;
} finally {
enCours = false;
}
}
function montrerIndice(): void {
if (!place) return;
const prochain = place.indices.find((i) => !indicesMontres.includes(i.texte));
if (prochain) indicesMontres = [...indicesMontres, prochain.texte];
}
function suivant(): void {
void chargerRound();
}
function montrerBilan(): void {
bilan = true;
}
function rejouerDefi(): void {
defi.reinitialiser();
joues = [];
bilan = false;
void chargerRound();
}
/** Génère une image canvas téléchargeable : score total + régions jouées. */
function partager(): void {
const canvas = document.createElement('canvas');
canvas.width = 1080;
canvas.height = 1080;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Fond sable + bandeau vert profond
ctx.fillStyle = '#f5efe2';
ctx.fillRect(0, 0, 1080, 1080);
ctx.fillStyle = '#0b3d2e';
ctx.fillRect(0, 0, 1080, 240);
ctx.fillStyle = '#fffdf8';
ctx.textAlign = 'center';
ctx.font = '800 96px system-ui, sans-serif';
ctx.fillText('JWE', 540, 130);
ctx.font = '600 44px system-ui, sans-serif';
ctx.fillText(t().modes.defi, 540, 200);
// Score total
ctx.fillStyle = '#0b3d2e';
ctx.font = '800 150px system-ui, sans-serif';
ctx.fillText(`${defi.total}`, 540, 420);
ctx.fillStyle = '#5a6b62';
ctx.font = '500 40px system-ui, sans-serif';
ctx.fillText(t().defi.scoreTotal, 540, 480);
// Manches : lieu, région, score
let y = 580;
for (const [i, m] of defi.manches.entries()) {
ctx.fillStyle = i % 2 === 0 ? '#e3efe7' : '#f5efe2';
ctx.fillRect(90, y - 44, 900, 74);
ctx.fillStyle = '#22302a';
ctx.textAlign = 'left';
ctx.font = '600 34px system-ui, sans-serif';
ctx.fillText(`${i + 1}. ${m.result.nom}`, 120, y);
ctx.fillStyle = '#17624a';
ctx.font = '500 30px system-ui, sans-serif';
ctx.fillText(t().regions[m.result.region], 120, y + 34);
ctx.textAlign = 'right';
ctx.fillStyle = '#0b3d2e';
ctx.font = '800 40px system-ui, sans-serif';
ctx.fillText(`${m.result.score}`, 960, y + 8);
y += 92;
}
ctx.fillStyle = '#5a6b62';
ctx.textAlign = 'center';
ctx.font = '500 30px system-ui, sans-serif';
ctx.fillText('labola.o-k-i.net', 540, 1010);
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = 'jwe-defi.png';
a.click();
}
onMount(() => {
// Nouveau défi : on repart de zéro quand on arrive sur la première manche.
if (estDefi && defi.termine) defi.reinitialiser();
void chargerRound();
});
</script>
<svelte:head>
<title>{t().app.titre}{t().modes[mode]}</title>
</svelte:head>
<div class="jeu">
{#if bilan}
<div class="bilan">
<h1>{t().defi.bilan}</h1>
<p class="total">
<span class="valeur">{defi.total}</span>
<span class="unite">{t().defi.scoreTotal}</span>
</p>
<div class="bilan-carte">
<GameMap interactive={false} pairs={paires} />
</div>
<ol class="recap">
{#each defi.manches as m, i (m.place.id)}
<li>
<span class="r-nom">{i + 1}. {m.result.nom}</span>
<span class="r-region">{t().regions[m.result.region]}</span>
<span class="r-score">{m.result.score} {t().resultat.points}</span>
</li>
{/each}
</ol>
<div class="bilan-actions">
<button type="button" class="btn btn-primaire" onclick={partager}>
{t().defi.partager}
</button>
<button type="button" class="btn btn-secondaire" onclick={rejouerDefi}>
{t().resultat.rejouer}
</button>
<a href="/" class="btn btn-tertiaire">{t().resultat.retourAccueil}</a>
</div>
<p class="legende">
<span class="puce joueur" aria-hidden="true"></span> {t().defi.taPosition}
<span class="puce reelle" aria-hidden="true"></span> {t().defi.positionReelle}
</p>
</div>
{:else if chargement}
<div class="etat"><p>{t().jeu.charger}</p></div>
{:else if erreur}
<div class="etat">
<p>{t().jeu.erreur}</p>
<button type="button" class="btn btn-secondaire" onclick={() => void chargerRound()}>
{t().jeu.reessayer}
</button>
</div>
{:else if place}
<div class="split" class:resultat={!!result}>
<section class="panneau-photo">
{#if result}
<div class="resultat-scroll">
<ResultPanel
{result}
creditPhoto={place.photo.credit}
labelBouton={estDefi && defiTermine ? t().resultat.voirBilan : t().resultat.suivant}
onsuivant={estDefi && defiTermine ? montrerBilan : suivant}
/>
</div>
{:else}
<PhotoPanel photo={place.photo} />
{/if}
</section>
<section class="panneau-carte">
<GameMap
bind:guess
real={result ? result.coordonnees : null}
interactive={!result}
onvalidate={() => void soumettre(false)}
/>
{#if estDefi && !defiTermine}
<span class="badge-manche">{t().jeu.round} {Math.min(mancheCourante, 5)} {t().jeu.sur} 5</span>
{/if}
{#if timerActif && !result}
<div class="badge-timer">
<TimerRing bind:this={timerRef} actif={!result} />
</div>
{/if}
</section>
</div>
{#if !result}
<div class="barre-actions">
<div class="actions-secondaires">
<button
type="button"
class="btn btn-tertiaire"
onclick={montrerIndice}
disabled={indicesMontres.length >= place.indices.length}
>
{t().jeu.indice}
<span class="penalite">{t().jeu.indicePenalite}</span>
</button>
<button type="button" class="btn btn-secondaire" onclick={() => void soumettre(true)}>
{t().jeu.passer}
</button>
</div>
<button
type="button"
class="btn btn-primaire valider"
disabled={!guess || enCours}
onclick={() => void soumettre(false)}
>
{t().jeu.valider}
</button>
</div>
{#if indicesMontres.length > 0}
<aside class="indices" aria-live="polite">
{#each indicesMontres as texte, i (texte)}
<p class="indice"><strong>{t().jeu.indice} {i + 1}</strong>{texte}</p>
{/each}
</aside>
{/if}
{#if !guess}
<p class="aide-carte">{t().jeu.poserMarqueur}</p>
{/if}
{/if}
{/if}
</div>
<style>
.jeu {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.etat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
color: var(--oki-encre-doux);
}
.split {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.6rem;
min-height: 0;
}
/* Mobile : photo 60 % haut, carte 40 % bas */
.panneau-photo {
flex: 0 0 58%;
min-height: 0;
}
.split.resultat .panneau-photo {
flex: 1 1 55%;
overflow-y: auto;
}
.panneau-carte {
position: relative;
flex: 1;
min-height: 220px;
}
.resultat-scroll {
height: 100%;
overflow-y: auto;
background: var(--oki-blanc);
border-radius: var(--oki-radius);
box-shadow: var(--oki-ombre);
padding: 1rem;
}
.badge-manche {
position: absolute;
top: 0.6rem;
right: 0.6rem;
background: rgba(255, 253, 248, 0.94);
color: var(--oki-vert);
border-radius: 999px;
padding: 0.25rem 0.8rem;
font-size: 0.82rem;
font-weight: 700;
box-shadow: var(--oki-ombre);
}
.badge-timer {
position: absolute;
top: 0.6rem;
left: 3.4rem; /* à droite des boutons zoom */
}
/* Barre d'actions : fixe en zone du pouce (tiers inférieur) */
.barre-actions {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding: 0.7rem 0.8rem calc(0.7rem + env(safe-area-inset-bottom));
background: var(--oki-blanc);
border-top: 1px solid var(--oki-sable-fonce);
}
.actions-secondaires {
display: flex;
gap: 0.5rem;
}
.actions-secondaires .btn {
padding: 0.7rem 1rem;
font-size: 0.92rem;
}
.penalite {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.8;
}
.valider {
flex: 0 0 auto;
padding: 0.9rem 1.4rem;
}
.indices {
padding: 0 1rem 0.5rem;
}
.indice {
margin: 0.25rem 0;
background: var(--oki-bleu-doux);
border-left: 4px solid var(--oki-bleu);
border-radius: var(--oki-radius-petit);
padding: 0.5rem 0.8rem;
font-size: 0.95rem;
}
.aide-carte {
margin: 0;
padding: 0 1rem 0.6rem;
text-align: center;
font-size: 0.88rem;
color: var(--oki-encre-doux);
}
/* Desktop : 50/50 */
@media (min-width: 768px) {
.split {
flex-direction: row;
}
.panneau-photo,
.split.resultat .panneau-photo {
flex: 1 1 50%;
}
.panneau-carte {
flex: 1 1 50%;
}
.barre-actions {
justify-content: center;
gap: 1.5rem;
}
}
/* — Écran bilan du défi — */
.bilan {
flex: 1;
width: 100%;
max-width: 860px;
margin: 0 auto;
padding: 1.2rem 1rem 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.bilan h1 {
margin: 0;
text-align: center;
color: var(--oki-vert);
}
.total {
margin: 0;
text-align: center;
}
.total .valeur {
font-size: 2.8rem;
font-weight: 800;
color: var(--oki-vert);
}
.total .unite {
margin-left: 0.4rem;
color: var(--oki-encre-doux);
}
.bilan-carte {
height: min(46vh, 420px);
}
.recap {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.recap li {
display: flex;
align-items: baseline;
gap: 0.6rem;
background: var(--oki-blanc);
border-radius: var(--oki-radius-petit);
box-shadow: var(--oki-ombre);
padding: 0.55rem 0.9rem;
}
.r-nom {
font-weight: 700;
color: var(--oki-vert);
}
.r-region {
flex: 1;
color: var(--oki-encre-doux);
font-size: 0.9rem;
}
.r-score {
font-weight: 800;
}
.bilan-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.7rem;
}
.legende {
margin: 0;
text-align: center;
font-size: 0.85rem;
color: var(--oki-encre-doux);
}
.puce.joueur {
color: var(--oki-corail);
}
.puce.reelle {
color: var(--oki-vert);
}
</style>
+12
View File
@@ -0,0 +1,12 @@
import { error } from '@sveltejs/kit';
import type { GameMode } from '$lib/types';
import type { PageLoad } from './$types';
const MODES: GameMode[] = ['monument', 'lieu', 'defi'];
export const load: PageLoad = ({ params }) => {
if (!MODES.includes(params.mode as GameMode)) {
error(404, 'Mode inconnu');
}
return { mode: params.mode as GameMode };
};
+64
View File
@@ -0,0 +1,64 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { build, files, version } from '$service-worker';
const sw = self as unknown as ServiceWorkerGlobalScope;
const APP_SHELL = `jwe-shell-${version}`; // immuable, recréé à chaque build
const PHOTOS = 'jwe-photos'; // stale-while-revalidate
const ASSETS = [...build, ...files];
sw.addEventListener('install', (event) => {
event.waitUntil(caches.open(APP_SHELL).then((cache) => cache.addAll(ASSETS)));
});
sw.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== APP_SHELL && key !== PHOTOS)
.map((key) => caches.delete(key))
)
)
);
});
function isPhoto(url: URL): boolean {
return (
url.hostname === 'upload.wikimedia.org' ||
(url.hostname === 'commons.wikimedia.org' && url.pathname.startsWith('/wiki/Special:FilePath'))
);
}
sw.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (event.request.method !== 'GET') return;
// App shell : cache d'abord
if (ASSETS.includes(url.pathname)) {
event.respondWith(caches.match(event.request).then((r) => r ?? fetch(event.request)));
return;
}
// Photos Wikimedia : stale-while-revalidate
if (isPhoto(url)) {
event.respondWith(
caches.open(PHOTOS).then(async (cache) => {
const cached = await cache.match(event.request);
const frais = fetch(event.request)
.then((res) => {
if (res.ok) void cache.put(event.request, res.clone());
return res;
})
.catch(() => cached as Response);
return cached ?? frais;
})
);
return;
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#0b3d2e"/>
<circle cx="256" cy="256" r="130" fill="none" stroke="#f5efe2" stroke-width="22"/>
<path d="M256 148 L312 330 L256 292 L200 330 Z" fill="#e4572e"/>
</svg>

After

Width:  |  Height:  |  Size: 269 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#0b3d2e"/>
<circle cx="256" cy="256" r="150" fill="none" stroke="#f5efe2" stroke-width="26"/>
<path d="M256 130 L322 344 L256 300 L190 344 Z" fill="#e4572e"/>
</svg>

After

Width:  |  Height:  |  Size: 277 B

+25
View File
@@ -0,0 +1,25 @@
{
"name": "JWE — Jeu de géolocalisation OKI",
"short_name": "JWE",
"description": "Devine la commune à partir d'une photo — Guadeloupe, Martinique, Guyane, La Réunion.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f5efe2",
"theme_color": "#0b3d2e",
"lang": "fr",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icon-maskable.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+6
View File
@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});