From d52034712dc33e9b2c7a512bc45b71651a931e7c Mon Sep 17 00:00:00 2001
From: sucupira
Date: Thu, 16 Jul 2026 21:58:32 -0400
Subject: [PATCH] fix(app): proxy photo opaque + EXIF strip + WebP (C4),
radius_eff communes immenses (A1), variante no-frwiki (A3), cache wiki 24h
---
app/package-lock.json | 608 +++++++++++++++++++++-
app/package.json | 4 +-
app/src/lib/components/PhotoPanel.svelte | 18 +-
app/src/lib/components/ResultPanel.svelte | 9 +
app/src/lib/components/WikiFiche.svelte | 46 +-
app/src/lib/i18n/fr.ts | 8 +
app/src/lib/i18n/gcf.ts | 8 +
app/src/lib/server/lieux.ts | 54 +-
app/src/lib/types.ts | 20 +-
app/src/routes/api/photo/[id]/+server.ts | 88 ++++
app/src/routes/api/wiki/[qid]/+server.ts | 24 +-
app/src/routes/explorer/+page.svelte | 8 +-
app/src/service-worker.ts | 4 +-
13 files changed, 875 insertions(+), 24 deletions(-)
create mode 100644 app/src/routes/api/photo/[id]/+server.ts
diff --git a/app/package-lock.json b/app/package-lock.json
index b0582b5..4bbd374 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -8,18 +8,30 @@
"name": "jwe",
"version": "2.0.0",
"dependencies": {
- "maplibre-gl": "^5.1.0"
+ "maplibre-gl": "^5.1.0",
+ "sharp": "^0.35.3"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@types/node": "^26.1.1",
"svelte": "^5.0.0",
"svelte-check": "^4.1.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
},
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
+ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -462,6 +474,506 @@
"node": ">=18"
}
},
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1246,6 +1758,16 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "26.1.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
+ "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@@ -1364,6 +1886,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/devalue": {
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
@@ -1879,6 +2410,18 @@
"node": ">=6"
}
},
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/set-cookie-parser": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
@@ -1886,6 +2429,55 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/sharp": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -2020,6 +2612,13 @@
"node": ">=6"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2034,6 +2633,13 @@
"node": ">=14.17"
}
},
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
diff --git a/app/package.json b/app/package.json
index 94dc567..b7fcaea 100644
--- a/app/package.json
+++ b/app/package.json
@@ -14,12 +14,14 @@
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@types/node": "^26.1.1",
"svelte": "^5.0.0",
"svelte-check": "^4.1.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
},
"dependencies": {
- "maplibre-gl": "^5.1.0"
+ "maplibre-gl": "^5.1.0",
+ "sharp": "^0.35.3"
}
}
diff --git a/app/src/lib/components/PhotoPanel.svelte b/app/src/lib/components/PhotoPanel.svelte
index bdbdd34..10ff0da 100644
--- a/app/src/lib/components/PhotoPanel.svelte
+++ b/app/src/lib/components/PhotoPanel.svelte
@@ -7,10 +7,26 @@
}
let { photo, revealed = false }: Props = $props();
+
+ // Proxy /api/photo : 3 largeurs disponibles, le navigateur choisit (C4).
+ const srcset = $derived(
+ photo.srcset
+ ? Object.entries(photo.srcset)
+ .map(([w, u]) => `${u} ${w}w`)
+ .join(', ')
+ : undefined
+ );
-
+
{#if revealed}
{photo.credit}
{/if}
diff --git a/app/src/lib/components/ResultPanel.svelte b/app/src/lib/components/ResultPanel.svelte
index cc3bd89..4b1d394 100644
--- a/app/src/lib/components/ResultPanel.svelte
+++ b/app/src/lib/components/ResultPanel.svelte
@@ -22,6 +22,9 @@
{#if result.distanceKm !== null}
{t().resultat.distance} : {result.distanceKm} {t().resultat.km}
+ {#if result.radiusEffKm !== null}
+ {t().resultat.rayonScore} : {result.radiusEffKm} {t().resultat.km}
+ {/if}
{/if}
{result.nom}
@@ -91,6 +94,12 @@
color: var(--oki-encre-doux);
}
+ .rayon {
+ margin: 0.1rem 0 0;
+ font-size: 0.82rem;
+ color: var(--oki-encre-doux);
+ }
+
.credit-photo {
margin: 0;
font-size: 0.72rem;
diff --git a/app/src/lib/components/WikiFiche.svelte b/app/src/lib/components/WikiFiche.svelte
index ba7c496..6181eea 100644
--- a/app/src/lib/components/WikiFiche.svelte
+++ b/app/src/lib/components/WikiFiche.svelte
@@ -12,8 +12,11 @@
let { nom, wikidataId, tonPositif, tonNeutre }: Props = $props();
- // undefined = chargement, null = pas de fiche (Cas B), WikiSummary = Cas A
+ // undefined = chargement, null = pas de fiche, WikiSummary = Cas A
let wiki = $state(undefined);
+ // Cas intermédiaire (audit A3) : QID présent, fiche Wikidata existante,
+ // mais pas d'article Wikipédia en français (404 {reason:'no-frwiki'}).
+ let sansFrwiki = $state(false);
onMount(() => {
if (!wikidataId) {
@@ -25,7 +28,19 @@
try {
const res = await fetch(`/api/wiki/${wikidataId}`);
if (annule) return;
- wiki = res.ok ? ((await res.json()) as WikiSummary) : null;
+ if (res.ok) {
+ wiki = (await res.json()) as WikiSummary;
+ } else {
+ if (res.status === 404) {
+ try {
+ const corps = (await res.json()) as { reason?: string };
+ sansFrwiki = corps.reason === 'no-frwiki';
+ } catch {
+ sansFrwiki = false;
+ }
+ }
+ wiki = null;
+ }
} catch {
if (!annule) wiki = null;
}
@@ -38,6 +53,7 @@
const lienContribution = $derived(
`https://fr.wikipedia.org/w/index.php?search=${encodeURIComponent(nom)}&title=Sp%C3%A9cial:Recherche`
);
+ const lienWikidata = $derived(`https://www.wikidata.org/wiki/${wikidataId}`);
{#if wiki === undefined}
@@ -61,6 +77,20 @@
{t().resultat.lireWiki} ↗
+{:else if sansFrwiki}
+
+
+ ◌ {t().lacuneFr.titre}
+ {t().lacuneFr.texte}
+
+
{:else}
@@ -169,6 +199,18 @@
background: #b98a2f;
}
+ .ctas {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.9rem;
+ }
+
+ .lien-wikidata {
+ color: var(--oki-bleu);
+ font-weight: 650;
+ }
+
.guide {
margin: 0.8rem 0 0;
font-size: 0.88rem;
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index 5ece930..79e14e2 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -57,6 +57,7 @@ const fr = {
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',
+ rayonScore: 'Rayon de notation',
commune: 'Commune',
region: 'Région',
suivant: 'Lieu suivant',
@@ -72,6 +73,13 @@ const fr = {
guide: 'Besoin d’aide pour démarrer ? Suis le guide OKI « Comment contribuer à Wikipédia ».',
guideLien: 'Ouvrir le guide de contribution'
},
+ lacuneFr: {
+ titre: 'Pas encore d’article en français',
+ texte:
+ 'La fiche Wikidata de ce lieu existe, mais personne n’a encore écrit l’article Wikipédia en français. Tu peux le traduire ou le créer : chaque article compte pour que nos territoires existent dans l’encyclopédie francophone.',
+ cta: 'Traduire ou créer l’article',
+ ctaWikidata: 'Voir la fiche Wikidata'
+ },
defi: {
bilan: 'Bilan du défi',
scoreTotal: 'Score total',
diff --git a/app/src/lib/i18n/gcf.ts b/app/src/lib/i18n/gcf.ts
index 772c3d8..2a9d48d 100644
--- a/app/src/lib/i18n/gcf.ts
+++ b/app/src/lib/i18n/gcf.ts
@@ -59,6 +59,7 @@ const gcf: Dict = {
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',
+ rayonScore: 'Réyon pou pwen yo',
commune: 'Komin',
region: 'Péyi',
suivant: 'Koté swivan',
@@ -74,6 +75,13 @@ const gcf: Dict = {
guide: 'Ou bizwen èd pou koumansé ? Swiv gid OKI « Kijan pou kontribyé sou Wikipédia ».',
guideLien: 'Ouvri gid kontribisyon-an'
},
+ lacuneFr: {
+ titre: 'Pwen blindé — Pa nyen an fransé',
+ texte:
+ 'Fich Wikidata koté sa-a égzisté, mé pésonn pòkò ékri atik Wikipédia-la an fransé. Ou sa tradwi’y oben réyé’y : chak atik ka konté pou téritwa nou égzisté adan ansiklopedi fransé-a.',
+ cta: 'Tradwi oben réyé atik-la',
+ ctaWikidata: 'Wè fich Wikidata-la'
+ },
defi: {
bilan: 'Bilan défi-a',
scoreTotal: 'Total pwen',
diff --git a/app/src/lib/server/lieux.ts b/app/src/lib/server/lieux.ts
index 1424ffe..9af7fae 100644
--- a/app/src/lib/server/lieux.ts
+++ b/app/src/lib/server/lieux.ts
@@ -74,7 +74,14 @@ export function getRandomLieu(options: {
return pool[Math.floor(Math.random() * pool.length)];
}
-/** Anti-triche : ne garde que les champs publiques pour /api/round. */
+/** Largeurs (px) servies par le proxy photo /api/photo/[id]?w=. */
+export const PHOTO_LARGEURS = [400, 800, 1200] as const;
+
+/**
+ * Anti-triche : ne garde que les champs publiques pour /api/round.
+ * L'URL photo passe par le proxy serveur (/api/photo/) : l'URL Wikimedia
+ * Commons d'origine contient le nom du fichier, qui révèle souvent le lieu (C4).
+ */
export function sanitizeRound(lieu: Lieu): RoundPlace {
return {
id: lieu.id,
@@ -82,15 +89,41 @@ export function sanitizeRound(lieu: Lieu): RoundPlace {
nom_creole: lieu.nom_creole,
categorie: lieu.categorie,
difficulte: lieu.difficulte,
- photo: lieu.photo,
+ photo: {
+ url: `/api/photo/${lieu.id}`,
+ srcset: {
+ '400': `/api/photo/${lieu.id}?w=400`,
+ '800': `/api/photo/${lieu.id}?w=800`,
+ '1200': `/api/photo/${lieu.id}?w=1200`
+ },
+ credit: lieu.photo.credit,
+ alt: lieu.photo.alt
+ },
indices: lieu.indices
};
}
+/**
+ * Rayon effectif (km) de la courbe de score (audit A1).
+ * Les communes guyanaises sont immenses (Saint-Laurent-du-Maroni ≈ 4 321 km²) :
+ * avec le seul rayon de difficulté, un guess à quelques dizaines de km y est
+ * injustement quasi nul. Quand `aire_commune_km2` est renseigné, le rayon est
+ * au moins celui du disque de même aire que la commune : sqrt(aire / π).
+ * Champ optionnel : sans lui, on retombe sur le rayon de difficulté.
+ */
+export function rayonEffectifKm(lieu: Lieu): number {
+ const base = RAYONS[lieu.difficulte];
+ const aire = lieu.aire_commune_km2;
+ if (typeof aire === 'number' && Number.isFinite(aire) && aire > 0) {
+ return Math.max(base, Math.sqrt(aire / Math.PI));
+ }
+ return base;
+}
+
/**
* 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.
+ * score = round(5000 × e^(−d/r_eff)) ; pénalité −10 % par indice ;
+ * bonus temps ≤ +20 % ; total plafonné à 5000.
*/
export function scoreGuess(
lieu: Lieu,
@@ -99,7 +132,8 @@ export function scoreGuess(
timeMs?: number
): GuessResult {
const distanceKm = haversineKm(guess, lieu.coordonnees);
- const base = Math.round(MAX_POINTS * Math.exp(-distanceKm / RAYONS[lieu.difficulte]));
+ const radiusEff = rayonEffectifKm(lieu);
+ const base = Math.round(MAX_POINTS * Math.exp(-distanceKm / radiusEff));
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) {
@@ -109,6 +143,7 @@ export function scoreGuess(
return {
distanceKm: Math.round(distanceKm * 10) / 10,
score,
+ radiusEffKm: Math.round(radiusEff * 10) / 10,
coordonnees: lieu.coordonnees,
commune: lieu.commune,
region: lieu.region,
@@ -123,6 +158,7 @@ export function skipResult(lieu: Lieu): GuessResult {
return {
distanceKm: null,
score: 0,
+ radiusEffKm: null,
coordonnees: lieu.coordonnees,
commune: lieu.commune,
region: lieu.region,
@@ -132,10 +168,14 @@ export function skipResult(lieu: Lieu): GuessResult {
};
}
-/** Catalogue Explorer : coords arrondies à 0.05° (mode sans score, fiches autorisées). */
+/**
+ * Catalogue Explorer : coords arrondies à 0.05° (mode sans score, fiches autorisées).
+ * L'`id` (uuid) n'est volontairement PAS exposé : il permettrait de corréler
+ * l'id d'un round avec la commune du lieu (fuite de réponse, audit C4).
+ * L'Explorer n'a pas besoin d'ids stables (le front utilise l'indice du tableau).
+ */
export function catalogueExplorer(): ExplorerLieu[] {
return lieux.map((l) => ({
- id: l.id,
nom: l.nom,
nom_creole: l.nom_creole,
region: l.region,
diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts
index f6c3a7b..e2e23b7 100644
--- a/app/src/lib/types.ts
+++ b/app/src/lib/types.ts
@@ -16,6 +16,11 @@ export interface Photo {
url: string;
credit: string;
alt: string;
+ /**
+ * URLs par largeur (px) pour `srcset`. Présent uniquement dans /api/round :
+ * pointe vers le proxy /api/photo/[id]?w=… (l'URL Commons d'origine n'est jamais exposée).
+ */
+ srcset?: Record<'400' | '800' | '1200', string>;
}
/** Lieu complet — serveur uniquement, jamais exposé tel quel au client. */
@@ -31,6 +36,12 @@ export interface Lieu {
wikidata_id: string | null;
photo: Photo;
indices: Indice[];
+ /**
+ * Superficie de la commune (km²) — champ optionnel du schéma.
+ * Sert à élargir le rayon de score pour les communes immenses (ex. Guyane) ;
+ * le scoring doit tolérer son absence (fallback = rayon de difficulté seul).
+ */
+ aire_commune_km2?: number;
}
/** Lieu SANITISÉ envoyé par GET /api/round (aucune coordonnée, commune, région, wikidata_id). */
@@ -48,6 +59,8 @@ export interface RoundPlace {
export interface GuessResult {
distanceKm: number | null;
score: number;
+ /** Rayon effectif utilisé par la courbe de score (km, arrondi à 0,1) — null si « Passer ». */
+ radiusEffKm: number | null;
coordonnees: Coordonnees;
commune: string;
region: Region;
@@ -56,9 +69,12 @@ export interface GuessResult {
wikidata_id: string | null;
}
-/** Entrée du catalogue Explorer : coords arrondies à 0.05°, mode sans score. */
+/**
+ * Entrée du catalogue Explorer : coords arrondies à 0.05°, mode sans score.
+ * Pas d'`id` : exposer l'uuid du lieu permettrait de corréler l'id d'un round
+ * (/api/round) avec la commune/région — fuite de réponse (audit C4).
+ */
export interface ExplorerLieu {
- id: string;
nom: string;
nom_creole: string;
region: Region;
diff --git a/app/src/routes/api/photo/[id]/+server.ts b/app/src/routes/api/photo/[id]/+server.ts
new file mode 100644
index 0000000..f5e063f
--- /dev/null
+++ b/app/src/routes/api/photo/[id]/+server.ts
@@ -0,0 +1,88 @@
+import sharp from 'sharp';
+import { getLieuById, PHOTO_LARGEURS } from '$lib/server/lieux';
+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
+
+type Largeur = (typeof PHOTO_LARGEURS)[number];
+const LARGEUR_DEFAUT: Largeur = 1200;
+
+/** Cache mémoire : clé `${id}:${w}`, TTL 24 h. */
+const cache = new Map();
+/** Déduplication des fetches en vol (même photo demandée en rafale). */
+const enVol = new Map>();
+
+/**
+ * Récupère la photo via l'URL Commons stockée dans le corpus (jamais d'URL
+ * fournie par le client — pas de proxy ouvert), puis la retraite avec sharp :
+ * - rotate() : applique l'orientation EXIF avant suppression des métadonnées ;
+ * - métadonnées : sharp ne conserve AUCUNE métadonnée (EXIF/GPS compris) sauf
+ * appel explicite à withMetadata() — le WebP produit est donc propre ;
+ * - conversion WebP + redimensionnement à la largeur demandée.
+ */
+async function chargerPhoto(id: string, w: Largeur): Promise {
+ const lieu = getLieuById(id);
+ if (!lieu) return null;
+ const res = await fetch(lieu.photo.url, { headers: { 'User-Agent': USER_AGENT } });
+ if (!res.ok) throw new Error(`commons ${res.status}`);
+ const brut = Buffer.from(await res.arrayBuffer());
+ return sharp(brut)
+ .rotate()
+ .resize({ width: w, withoutEnlargement: true })
+ .webp({ quality: 82 })
+ .toBuffer();
+}
+
+/**
+ * GET /api/photo/[id]?w=400|800|1200 → image/webp.
+ * `id` = uuid du lieu (déjà exposé par /api/round). 404 si lieu inconnu.
+ * L'URL ainsi produite est opaque : elle ne révèle ni le nom du fichier
+ * Commons ni le lieu (audit C4).
+ */
+export const GET: RequestHandler = async ({ params, url }) => {
+ const id = params.id;
+ const wParam = url.searchParams.get('w');
+ let w: Largeur = LARGEUR_DEFAUT;
+ if (wParam !== null) {
+ const n = Number(wParam);
+ if (!(PHOTO_LARGEURS as readonly number[]).includes(n)) {
+ return new Response('Largeur invalide (400, 800 ou 1200)', { status: 400 });
+ }
+ w = n as Largeur;
+ }
+
+ const cle = `${id}:${w}`;
+ const hit = cache.get(cle);
+ if (hit && hit.expires > Date.now()) {
+ return reponseWebp(hit.data);
+ }
+
+ // 404 immédiat si l'id est inconnu (avant tout fetch amont).
+ if (!getLieuById(id)) {
+ return new Response('Lieu inconnu', { status: 404 });
+ }
+
+ try {
+ let promesse = enVol.get(cle);
+ if (!promesse) {
+ promesse = chargerPhoto(id, w).finally(() => enVol.delete(cle));
+ enVol.set(cle, promesse);
+ }
+ const data = await promesse;
+ if (!data) return new Response('Lieu inconnu', { status: 404 });
+ cache.set(cle, { expires: Date.now() + TTL_MS, data });
+ return reponseWebp(data);
+ } catch {
+ return new Response('Photo indisponible', { status: 502 });
+ }
+};
+
+function reponseWebp(data: Buffer): Response {
+ return new Response(new Uint8Array(data), {
+ headers: {
+ 'Content-Type': 'image/webp',
+ 'Cache-Control': 'public, max-age=86400, immutable'
+ }
+ });
+}
diff --git a/app/src/routes/api/wiki/[qid]/+server.ts b/app/src/routes/api/wiki/[qid]/+server.ts
index 7bc6173..e9baa8f 100644
--- a/app/src/routes/api/wiki/[qid]/+server.ts
+++ b/app/src/routes/api/wiki/[qid]/+server.ts
@@ -4,6 +4,7 @@ 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_CONTROL = 'public, max-age=86400';
const cache = new Map();
@@ -23,7 +24,7 @@ interface RestSummary {
thumbnail?: { source?: string };
}
-/** GET /api/wiki/[qid] → extrait Wikipédia FR normalisé (404 si pas de sitelink fr). */
+/** GET /api/wiki/[qid] → extrait Wikipédia FR normalisé (404 {reason:'no-frwiki'} si pas de sitelink fr). */
export const GET: RequestHandler = async ({ params, fetch }) => {
const qid = params.qid;
if (!/^Q\d+$/.test(qid)) {
@@ -32,26 +33,37 @@ export const GET: RequestHandler = async ({ params, fetch }) => {
const cached = cache.get(qid);
if (cached && cached.expires > Date.now()) {
- return json(cached.summary, { headers: { 'Cache-Control': 'public, max-age=3600' } });
+ return json(cached.summary, { headers: { 'Cache-Control': CACHE_CONTROL } });
}
const headers = { 'User-Agent': USER_AGENT, Accept: 'application/json' };
// 1. Wikidata : sitelink frwiki (+ libellé fr en fallback)
+ // Fallback de langue : fr UNIQUEMENT, volontairement sans repli sur en —
+ // l'absence d'article français devient un appel à contribution (Cas B), pas
+ // une redirection silencieuse vers Wikipédia en anglais.
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];
+ // L'API résout les redirections : l'entité peut être indexée par le QID cible.
+ const entities = ((await res.json()) as WbResponse).entities ?? {};
+ entity = entities[qid] ?? Object.values(entities)[0];
} 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 });
+ // QID existant mais pas d'article frwiki (cas intermédiaire, audit A3) :
+ // `reason: no-frwiki` permet au front d'afficher la variante « Pwen blindé —
+ // Pa nyen an fransé » (fiche Wikidata présente, article FR à traduire/créer),
+ // distincte du Cas B « lacune totale » (wikidata_id null côté lieu).
+ return json(
+ { reason: 'no-frwiki' },
+ { status: 404, headers: { 'Cache-Control': CACHE_CONTROL } }
+ );
}
// 2. Wikipédia FR : résumé de l'article
@@ -77,5 +89,5 @@ export const GET: RequestHandler = async ({ params, fetch }) => {
};
cache.set(qid, { expires: Date.now() + TTL_MS, summary });
- return json(summary, { headers: { 'Cache-Control': 'public, max-age=3600' } });
+ return json(summary, { headers: { 'Cache-Control': CACHE_CONTROL } });
};
diff --git a/app/src/routes/explorer/+page.svelte b/app/src/routes/explorer/+page.svelte
index 86b0c73..540820b 100644
--- a/app/src/routes/explorer/+page.svelte
+++ b/app/src/routes/explorer/+page.svelte
@@ -18,7 +18,9 @@
);
const markers = $derived(
lieuxFiltres.map((l) => ({
- id: l.id,
+ // Id éphémère côté client (indice dans le catalogue) : /api/lieux n'expose
+ // plus l'uuid du lieu, pour ne pas corréler un id de round avec sa commune (C4).
+ id: String(lieux.indexOf(l)),
lat: l.coordonnees.lat,
lon: l.coordonnees.lon,
label: `${l.nom} — ${l.commune}`
@@ -26,7 +28,7 @@
);
function ouvrir(id: string): void {
- selection = lieux.find((l) => l.id === id) ?? null;
+ selection = lieux[Number(id)] ?? null;
}
onMount(() => {
@@ -103,7 +105,7 @@
{t().resultat.commune} : {selection.commune} — {t().regions[selection.region]}
- {#key selection.id}
+ {#key selection.nom}
{/key}
{:else}
diff --git a/app/src/service-worker.ts b/app/src/service-worker.ts
index 49e829b..5a926a5 100644
--- a/app/src/service-worker.ts
+++ b/app/src/service-worker.ts
@@ -31,7 +31,9 @@ sw.addEventListener('activate', (event) => {
function isPhoto(url: URL): boolean {
return (
url.hostname === 'upload.wikimedia.org' ||
- (url.hostname === 'commons.wikimedia.org' && url.pathname.startsWith('/wiki/Special:FilePath'))
+ (url.hostname === 'commons.wikimedia.org' && url.pathname.startsWith('/wiki/Special:FilePath')) ||
+ // Proxy photo maison (mode jeu) — réponses immutables, WebP sans métadonnées
+ (url.origin === sw.location.origin && url.pathname.startsWith('/api/photo/'))
);
}