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
+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;
}
});