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:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
build
|
||||
.svelte-kit
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.vite
|
||||
+28
@@ -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).
|
||||
Generated
+2140
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 d’affilé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 d’indice 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 à l’accueil'
|
||||
},
|
||||
lacune: {
|
||||
titre: 'Pwen blindé — Sa pa dokimanté',
|
||||
texte:
|
||||
'Ce lieu existe, mais il n’a pas de page sur Wikipédia ni de fiche sur Wikidata. Pour nos régions, c’est 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 l’intelligence artificielle).',
|
||||
cta: 'Kontribyé sou Wikipédia',
|
||||
guide: 'Besoin d’aide 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 l’image',
|
||||
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;
|
||||
@@ -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 asou’y.',
|
||||
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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}));
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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' }
|
||||
});
|
||||
};
|
||||
@@ -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' }
|
||||
});
|
||||
};
|
||||
@@ -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' } });
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user