diff --git a/VERSION b/VERSION index e0d61b5b..f69abe41 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.9.4 +3.9.7 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 231c9c65..be25ed4a 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -56,6 +56,15 @@ async def admin_react_overview(request: Request): "player_theme": cfg.get("player_theme"), "map_center": cfg.get("map_center"), "map_zoom": cfg.get("map_zoom"), + "login_title": cfg.get("login_title"), + "login_subtitle": cfg.get("login_subtitle"), + "login_instructions": cfg.get("login_instructions"), + "prologue_title": cfg.get("prologue_title"), + "prologue_subtitle": cfg.get("prologue_subtitle"), + "prologue_image_url": cfg.get("prologue_image_url"), + "prologue_body": cfg.get("prologue_body"), + "mapbox_token": cfg.get("mapbox_token"), + "mapbox_style": cfg.get("mapbox_style"), }, "counts": { "players": len(cfg.get("players", [])) if isinstance(cfg.get("players"), list) else 0, @@ -148,17 +157,20 @@ async def save_config_endpoint(request: Request): updated = { **cfg, - "site_name": _as_str(incoming.get("site_name") or cfg.get("site_name")).strip() or "SAGA Engine", - "admin_title": _as_str(incoming.get("admin_title") or cfg.get("admin_title")).strip() or "Mission Control", - "admin_subtitle": _as_str(incoming.get("admin_subtitle") or cfg.get("admin_subtitle")).strip() or "Map-first control panel", - "login_subtitle": _as_str(incoming.get("login_subtitle") or cfg.get("login_subtitle", "Protected access")).strip(), - "story_title": _as_str(incoming.get("story_title") or "").strip(), - "story_text": _as_str(incoming.get("story_text") or "").strip(), - "prologue_title": _as_str(incoming.get("prologue_title") or "").strip(), - "prologue_subtitle": _as_str(incoming.get("prologue_subtitle") or "").strip(), - "prologue_body": _as_str(incoming.get("prologue_body") or "").strip(), - "mapbox_token": _as_str(incoming.get("mapbox_token") or "").strip(), - "mapbox_style": _as_str(incoming.get("mapbox_style") or "").strip(), + "site_name": _as_str(incoming.get("site_name") if "site_name" in incoming else cfg.get("site_name")).strip() or "SAGA Engine", + "admin_title": _as_str(incoming.get("admin_title") if "admin_title" in incoming else cfg.get("admin_title")).strip() or "Mission Control", + "admin_subtitle": _as_str(incoming.get("admin_subtitle") if "admin_subtitle" in incoming else cfg.get("admin_subtitle")).strip() or "Map-first control panel", + "login_title": _as_str(incoming.get("login_title") if "login_title" in incoming else cfg.get("login_title", "")).strip(), + "login_subtitle": _as_str(incoming.get("login_subtitle") if "login_subtitle" in incoming else cfg.get("login_subtitle", "Protected access")).strip(), + "login_instructions": _as_str(incoming.get("login_instructions") if "login_instructions" in incoming else cfg.get("login_instructions", "")).strip(), + "story_title": _as_str(incoming.get("story_title") if "story_title" in incoming else cfg.get("story_title", "")).strip(), + "story_text": _as_str(incoming.get("story_text") if "story_text" in incoming else cfg.get("story_text", "")).strip(), + "prologue_title": _as_str(incoming.get("prologue_title") if "prologue_title" in incoming else cfg.get("prologue_title", "")).strip(), + "prologue_subtitle": _as_str(incoming.get("prologue_subtitle") if "prologue_subtitle" in incoming else cfg.get("prologue_subtitle", "")).strip(), + "prologue_body": _as_str(incoming.get("prologue_body") if "prologue_body" in incoming else cfg.get("prologue_body", "")).strip(), + "prologue_image_url": _as_str(incoming.get("prologue_image_url") if "prologue_image_url" in incoming else cfg.get("prologue_image_url", "")).strip(), + "mapbox_token": _as_str(incoming.get("mapbox_token") if "mapbox_token" in incoming else cfg.get("mapbox_token", "")).strip(), + "mapbox_style": _as_str(incoming.get("mapbox_style") if "mapbox_style" in incoming else cfg.get("mapbox_style", "")).strip(), } raw_center = incoming.get("map_center") diff --git a/backend/app/runtime/minigames.py b/backend/app/runtime/minigames.py index 59ead9d1..74288dc4 100644 --- a/backend/app/runtime/minigames.py +++ b/backend/app/runtime/minigames.py @@ -14,10 +14,13 @@ "audio_challenge", } +import unicodedata + def _as_str(value, default=""): if value is None: return default - return str(value) + s = str(value) + return unicodedata.normalize("NFC", s) def _clean_code(value): return _as_str(value).strip().upper() diff --git a/data/admin_sessions.json b/data/admin_sessions.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/data/admin_sessions.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/data/game_timers.json b/data/game_timers.json new file mode 100644 index 00000000..af863ada --- /dev/null +++ b/data/game_timers.json @@ -0,0 +1,5 @@ +{ + "PLAYER 1": { + "stage_times_ms": {} + } +} \ No newline at end of file diff --git a/data/stages.json b/data/stages.json index 2ebe78c0..bc14d2b2 100755 --- a/data/stages.json +++ b/data/stages.json @@ -1,7 +1,7 @@ [ { "id": 1, - "title": "1. Aparcamiento do Lago (Checkpoint Inicial)", + "title": "Aparcamiento do Lago (Checkpoint Inicial)", "lat": 42.36296536346519, "lon": -8.67393525063518, "type": "signal_hunt", @@ -18,7 +18,7 @@ }, { "id": 2, - "title": "2. Senda de Castiñeiras (Minijuego 1)", + "title": "Senda de Castiñeiras (Minijuego 1)", "lat": 42.3665, "lon": -8.662, "type": "circuit_matrix", @@ -40,7 +40,7 @@ }, { "id": 3, - "title": "3. Mámoas de Castiñeiras (Coleccionable 1)", + "title": "Mámoas de Castiñeiras (Coleccionable 1)", "lat": 42.36, "lon": -8.654, "type": "signal_hunt", @@ -61,7 +61,7 @@ }, { "id": 4, - "title": "4. Mirador de Tordóia (Baliza QR 1)", + "title": "Mirador de Tordóia (Baliza QR 1)", "lat": 42.3475, "lon": -8.662, "type": "signal_hunt", @@ -82,7 +82,7 @@ }, { "id": 5, - "title": "5. Repetidores do Monte (Minijuego 2)", + "title": "Repetidores do Monte (Minijuego 2)", "lat": 42.3495, "lon": -8.672, "type": "audio_challenge", @@ -101,7 +101,7 @@ }, { "id": 6, - "title": "6. Torre de Cotorredondo (Baliza QR 2)", + "title": "Torre de Cotorredondo (Baliza QR 2)", "lat": 42.3533, "lon": -8.6757, "type": "signal_hunt", @@ -122,7 +122,7 @@ }, { "id": 7, - "title": "7. Campo de Tiro (Coleccionable 2)", + "title": "Campo de Tiro (Coleccionable 2)", "lat": 42.3515, "lon": -8.687, "type": "signal_hunt", @@ -143,7 +143,7 @@ }, { "id": 8, - "title": "8. Senda Forestal Norte (Coleccionable 3)", + "title": "Senda Forestal Norte (Coleccionable 3)", "lat": 42.3585, "lon": -8.685, "type": "signal_hunt", @@ -164,7 +164,7 @@ }, { "id": 9, - "title": "9. Baixada ao Botánico (Minijuego 3)", + "title": "Baixada ao Botánico (Minijuego 3)", "lat": 42.362, "lon": -8.679, "type": "motion_challenge", @@ -185,7 +185,7 @@ }, { "id": 10, - "title": "10. Xardín Botánico (Meta Final)", + "title": "Xardín Botánico (Meta Final)", "lat": 42.3635, "lon": -8.675, "type": "circuit_matrix", diff --git a/deploy_src.tar.gz b/deploy_src.tar.gz index cd178846..97014b9f 100644 Binary files a/deploy_src.tar.gz and b/deploy_src.tar.gz differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8b36e9c6..81d7adb4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,14 +1,14 @@ { "name": "saga-engine-frontend", - "version": "2.1.0", + "version": "3.9.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "saga-engine-frontend", - "version": "2.1.0", + "version": "3.9.7", "dependencies": { - "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query": "^5.101.4", "jsqr": "^1.4.0", "jszip": "^3.10.1", "leaflet": "^1.9.4", @@ -31,8 +31,8 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", "husky": "^9.1.7", - "lint-staged": "^17.0.8", - "prettier": "^3.9.4", + "lint-staged": "^17.2.0", + "prettier": "^3.9.6", "typescript": "^5.6.0", "vite": "^8.1.3" } @@ -859,20 +859,22 @@ "dev": true }, "node_modules/@tanstack/query-core": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", - "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", - "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.2" + "@tanstack/query-core": "5.101.4" }, "funding": { "type": "github", @@ -1239,33 +1241,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1606,37 +1581,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1843,24 +1787,6 @@ "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==", "dev": true }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -2398,12 +2324,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2597,18 +2517,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3134,21 +3042,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -3812,13 +3705,13 @@ } }, "node_modules/lint-staged": { - "version": "17.0.8", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", - "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.2.0.tgz", + "integrity": "sha512-FchGnFe4i4B1C/a35SPU9bNGPEHSC1+1iV0plLjzBmKVe9klZrlRfSgK6Cw4VeHyqOXbJUXP0vON61uRftNQ0A==", "dev": true, + "license": "MIT", "dependencies": { - "listr2": "^10.2.1", - "picomatch": "^4.0.4", + "picomatch": "^4.0.5", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, @@ -3835,22 +3728,6 @@ "yaml": "^2.9.0" } }, - "node_modules/listr2": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.2.tgz", - "integrity": "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==", - "dev": true, - "dependencies": { - "cli-truncate": "^5.2.0", - "eventemitter3": "^5.0.4", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^10.0.0" - }, - "engines": { - "node": ">=22.13.0" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3873,87 +3750,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3983,18 +3779,6 @@ "node": ">= 0.4" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -4189,21 +3973,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4327,10 +4096,11 @@ "dev": true }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -4386,9 +4156,9 @@ } }, "node_modules/prettier": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", - "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -4574,22 +4344,6 @@ "node": ">=4" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -4601,12 +4355,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -4916,46 +4664,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4996,22 +4704,6 @@ "node": ">=0.6.19" } }, - "node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "dev": true, - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -5106,21 +4798,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -5579,35 +5256,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", - "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.2.3", - "string-width": "^8.2.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3dbb63fe..abafba36 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "saga-engine-frontend", "private": true, - "version": "3.9.2", + "version": "3.9.7", "type": "module", "scripts": { "dev": "vite", @@ -10,7 +10,7 @@ "prepare": "husky" }, "dependencies": { - "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query": "^5.101.4", "jsqr": "^1.4.0", "jszip": "^3.10.1", "leaflet": "^1.9.4", @@ -33,8 +33,8 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", "husky": "^9.1.7", - "lint-staged": "^17.0.8", - "prettier": "^3.9.4", + "lint-staged": "^17.2.0", + "prettier": "^3.9.6", "typescript": "^5.6.0", "vite": "^8.1.3" } diff --git a/frontend/src/admin/AdminApp.tsx b/frontend/src/admin/AdminApp.tsx index 077d2b48..51d52e5d 100644 --- a/frontend/src/admin/AdminApp.tsx +++ b/frontend/src/admin/AdminApp.tsx @@ -173,6 +173,7 @@ export default function AdminApp() { } as PublicConfig setPlayerDrafts(buildPlayerDrafts(overview?.profiles || profiles || [], sourceConfig)) + setMissionDraft(buildMissionDraft(sourceConfig as unknown as Record)) }, [overviewReady, overview, profiles, config]) useMemo(() => { @@ -213,11 +214,14 @@ export default function AdminApp() { site_name: getConfigTextValue(source, 'site_name', config?.site_name || ''), admin_title: getConfigTextValue(source, 'admin_title', config?.admin_title || ''), admin_subtitle: getConfigTextValue(source, 'admin_subtitle', config?.admin_subtitle || ''), + login_title: getConfigTextValue(source, 'login_title', ''), login_subtitle: getConfigTextValue(source, 'login_subtitle', ''), + login_instructions: getConfigTextValue(source, 'login_instructions', ''), story_title: getConfigTextValue(source, 'story_title', ''), story_text: getConfigTextValue(source, 'story_text', ''), prologue_title: getConfigTextValue(source, 'prologue_title', ''), prologue_subtitle: getConfigTextValue(source, 'prologue_subtitle', ''), + prologue_image_url: getConfigTextValue(source, 'prologue_image_url', ''), prologue_body: getConfigTextValue(source, 'prologue_body', ''), player_theme: getConfigTextValue(source, 'player_theme', config?.player_theme || 'classic'), mapbox_token: getConfigTextValue(source, 'mapbox_token', config?.mapbox_token || ''), @@ -267,11 +271,14 @@ export default function AdminApp() { site_name: missionDraft.site_name || 'SAGA Engine', admin_title: missionDraft.admin_title || 'Mission editor', admin_subtitle: missionDraft.admin_subtitle || 'Map-first control panel', + login_title: missionDraft.login_title || '', login_subtitle: missionDraft.login_subtitle || '', + login_instructions: missionDraft.login_instructions || '', story_title: missionDraft.story_title || '', story_text: missionDraft.story_text || '', prologue_title: missionDraft.prologue_title || '', prologue_subtitle: missionDraft.prologue_subtitle || '', + prologue_image_url: missionDraft.prologue_image_url || '', prologue_body: missionDraft.prologue_body || '', player_theme: missionDraft.player_theme || 'classic', mapbox_token: missionDraft.mapbox_token || '', @@ -683,7 +690,6 @@ export default function AdminApp() { }) if (movedStage) { - setSelectedStage(movedStage) setSaveState('dirty') setLocalNotice('Orden de ruta actualizado en local. Pulsa Guardar para persistir.') } diff --git a/frontend/src/admin/AdminMissionMap.tsx b/frontend/src/admin/AdminMissionMap.tsx index 395e0b36..35019163 100644 --- a/frontend/src/admin/AdminMissionMap.tsx +++ b/frontend/src/admin/AdminMissionMap.tsx @@ -138,6 +138,7 @@ export default function AdminMissionMap({ const mapRootRef = useRef(null) const mapRef = useRef(null) const layersRef = useRef([]) + const routeLayersRef = useRef([]) const heatmapLayerRef = useRef([]) const dragClickSuppressUntilRef = useRef(0) const [localShowHeatmap, setLocalShowHeatmap] = useState(false) @@ -152,6 +153,9 @@ export default function AdminMissionMap({ const mappedStages = useMemo(() => stages.filter(hasCoords), [stages]) + const tileLayerRef = useRef(null) + const [mapTileMode, setMapTileMode] = useState<'satellite' | 'satellite-osm' | 'topo'>('satellite-osm') + useEffect(() => { if (!mapRootRef.current || mapRef.current) return @@ -161,13 +165,10 @@ export default function AdminMissionMap({ doubleClickZoom: false, }) - L.tileLayer('/map-tiles/{z}/{x}/{y}.png', { - maxZoom: 19, - attribution: - '© OpenStreetMap', - }).addTo(map) + const tileGroup = L.layerGroup().addTo(map) + tileLayerRef.current = tileGroup - map.setView([42.26, -8.86], 13) + map.setView([42.36, -8.67], 14) mapRef.current = map return () => { @@ -178,31 +179,52 @@ export default function AdminMissionMap({ } }, []) - useEffect(() => { - const map = mapRef.current - if (!map || !onCreateStageAt) return - - const handleMapClick = (event: L.LeafletMouseEvent) => { - // #233 v16: map click/tap opens a tiny create/discard popover. - // Nothing is added to local mission state until the admin confirms. - if ( - map.getContainer().classList.contains('admin-map-dragging-node') || - Date.now() < dragClickSuppressUntilRef.current - ) - return + const [routeMetricsHUD, setRouteMetricsHUD] = useState({ distanceKm: 0, durationMin: 0, elevationM: 0 }) - onCreateStageAt(event.latlng.lat, event.latlng.lng, { - x: event.originalEvent.clientX, - y: event.originalEvent.clientY, - }) - } + useEffect(() => { + const tileGroup = tileLayerRef.current + if (!tileGroup) return - map.on('click', handleMapClick) + tileGroup.clearLayers() - return () => { - map.off('click', handleMapClick) + if (mapTileMode === 'satellite' || mapTileMode === 'satellite-osm') { + const esriSat = L.tileLayer( + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + { maxNativeZoom: 18, maxZoom: 21 } + ) + tileGroup.addLayer(esriSat) + + if (mapTileMode === 'satellite-osm') { + const osmOverlay = L.tileLayer( + 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + { maxNativeZoom: 19, maxZoom: 21, opacity: 0.45 } + ) + tileGroup.addLayer(osmOverlay) + } else { + const waymarkedTrails = L.tileLayer( + 'https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png', + { maxNativeZoom: 18, maxZoom: 21, opacity: 0.85 } + ) + const esriRoads = L.tileLayer( + 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}', + { maxNativeZoom: 18, maxZoom: 21, opacity: 0.95 } + ) + const esriPlaces = L.tileLayer( + 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', + { maxNativeZoom: 18, maxZoom: 21, opacity: 0.95 } + ) + tileGroup.addLayer(waymarkedTrails) + tileGroup.addLayer(esriRoads) + tileGroup.addLayer(esriPlaces) + } + } else { + const topoMap = L.tileLayer('/map-tiles/{z}/{x}/{y}.png', { + maxNativeZoom: 18, + maxZoom: 21, + }) + tileGroup.addLayer(topoMap) } - }, [onCreateStageAt]) + }, [mapTileMode]) useEffect(() => { const map = mapRef.current @@ -211,6 +233,199 @@ export default function AdminMissionMap({ layersRef.current.forEach((layer) => layer.remove()) layersRef.current = [] + const oldRouteLayers = routeLayersRef.current + routeLayersRef.current = [] + + const orderedStages = [...mappedStages].sort((a, b) => a.index - b.index) + const waypoints: [number, number][] = [] + + orderedStages.forEach((stage) => { + const coords = getStageCoords(stage) + if (coords) waypoints.push(coords) + }) + + if (waypoints.length >= 2) { + const coordString = waypoints.map(([lat, lon]) => `${lon},${lat}`).join(';') + const osrmUrl = `https://router.project-osrm.org/route/v1/foot/${coordString}?overview=false&geometries=geojson&steps=true` + + fetch(osrmUrl) + .then((res) => res.json()) + .then((data) => { + oldRouteLayers.forEach((layer) => layer.remove()) + if (data && data.routes && data.routes[0] && data.routes[0].legs) { + const route = data.routes[0] + const distanceKm = route.distance / 1000.0 + const durationMin = Math.round((route.duration || (distanceKm * 900)) / 60) + const elevationM = Math.round(distanceKm * 48) + + setRouteMetricsHUD({ distanceKm, durationMin, elevationM }) + + // Extract all coordinates for GPX + const allCoords: [number, number][] = [] + if (route.legs) { + route.legs.forEach((leg: any) => { + leg.steps.forEach((step: any) => { + step.geometry.coordinates.forEach(([lon, lat]: [number, number]) => { + allCoords.push([lat, lon]) + }) + }) + }) + } + + // Dispatch metrics back to parent + window.dispatchEvent(new CustomEvent('saga-route-metrics', { + detail: { distanceKm, elevationM, durationMin, mappedCount: waypoints.length, routeCoords: allCoords } + })) + + route.legs.forEach((leg: any, i: number) => { + const legCoords: [number, number][] = [] + leg.steps.forEach((step: any) => { + step.geometry.coordinates.forEach(([lon, lat]: [number, number]) => { + legCoords.push([lat, lon]) + }) + }) + + const fromNode = orderedStages[i] + const toNode = orderedStages[i + 1] + const legTitle = `🟢 Tramo ${i + 1}: ${fromNode?.title || 'Nodo A'} ➡️ ${toNode?.title || 'Nodo B'} (Pasa ratón para VER EN ROJO / Arrastra la línea para moldear camino)` + + // Outer Dark Emerald Border + const outerLine = L.polyline(legCoords, { + color: '#047857', + weight: 11, + opacity: 0.8, + lineCap: 'round', + lineJoin: 'round', + }).addTo(map) + + // Inner Vivid Emerald Green Polyline (Base route color: Green) + const innerLine = L.polyline(legCoords, { + color: '#10b981', + weight: 6, + opacity: 0.95, + lineCap: 'round', + lineJoin: 'round', + }).addTo(map) + + innerLine.bindTooltip(legTitle, { sticky: true, className: 'saga-route-tooltip-red' }) + + // Hover state: turns BRIGHT VIVID RED (#ff0000) + innerLine.on('mouseover', () => { + innerLine.setStyle({ color: '#ff0000', weight: 9, opacity: 1.0 }) + outerLine.setStyle({ color: '#991b1b', weight: 14, opacity: 0.9 }) + }) + + innerLine.on('mouseout', () => { + innerLine.setStyle({ color: '#10b981', weight: 6, opacity: 0.95 }) + outerLine.setStyle({ color: '#047857', weight: 11, opacity: 0.8 }) + }) + + // Real-time Dynamic Polyline Dragging: Snapping to mountain roads as mouse moves + let previewLine: L.Polyline | null = null + let isDraggingLine = false + let lastFetchTime = 0 + + const handleMouseDown = (e: L.LeafletMouseEvent) => { + L.DomEvent.stopPropagation(e.originalEvent) + isDraggingLine = true + map.dragging.disable() + + innerLine.setStyle({ color: '#ff0000', weight: 9 }) + outerLine.setStyle({ color: '#991b1b', weight: 14 }) + + const fromPoint = legCoords[0] + const toPoint = legCoords[legCoords.length - 1] + + const handleMouseMove = (moveEvt: L.LeafletMouseEvent) => { + if (!isDraggingLine) return + const curPoint = moveEvt.latlng + const now = Date.now() + + // Fast local preview immediately + if (!previewLine) { + previewLine = L.polyline([fromPoint, [curPoint.lat, curPoint.lng], toPoint], { + color: '#ff0000', + weight: 7, + dashArray: '8, 8', + opacity: 0.95, + }).addTo(map) + } + + // Throttled OSRM dynamic road snapping (~120ms) + if (now - lastFetchTime > 120) { + lastFetchTime = now + const osrmUrl = `https://router.project-osrm.org/route/v1/foot/${fromPoint[1]},${fromPoint[0]};${curPoint.lng},${curPoint.lat};${toPoint[1]},${toPoint[0]}?overview=full&geometries=geojson` + fetch(osrmUrl) + .then((res) => res.json()) + .then((data) => { + if (isDraggingLine && data.routes?.[0]?.geometry?.coordinates) { + const routePts: [number, number][] = data.routes[0].geometry.coordinates.map( + ([lon, lat]: [number, number]) => [lat, lon] + ) + if (previewLine) { + previewLine.setLatLngs(routePts) + previewLine.setStyle({ dashArray: undefined, color: '#ff0000', weight: 8 }) + } + } + }) + .catch(() => {}) + } + } + + const handleMouseUp = (upEvt: L.LeafletMouseEvent) => { + map.off('mousemove', handleMouseMove) + map.off('mouseup', handleMouseUp) + map.dragging.enable() + + if (previewLine) { + map.removeLayer(previewLine) + previewLine = null + } + + if (isDraggingLine && onMoveStage) { + const dropPt = upEvt.latlng + if (dropPt && fromNode) { + // Adjust stage waypoint position along trail smoothly + onMoveStage(fromNode, dropPt.lat, dropPt.lng) + } + } + isDraggingLine = false + innerLine.setStyle({ color: '#10b981', weight: 6 }) + outerLine.setStyle({ color: '#047857', weight: 11 }) + } + + map.on('mousemove', handleMouseMove) + map.on('mouseup', handleMouseUp) + } + + innerLine.on('mousedown', handleMouseDown) + outerLine.on('mousedown', handleMouseDown) + + routeLayersRef.current.push(outerLine, innerLine) + }) + } else { + fallbackLines(map) + } + }) + .catch(() => { + oldRouteLayers.forEach((layer) => layer.remove()) + fallbackLines(map) + }) + } + + function fallbackLines(m: L.Map) { + const fallbackLine = L.polyline(waypoints, { + color: '#dc2626', + weight: 6, + opacity: 0.9, + dashArray: '8, 8', + }).addTo(m) + routeLayersRef.current.push(fallbackLine) + window.dispatchEvent(new CustomEvent('saga-route-metrics', { + detail: { distanceKm: 0, elevationM: 0, mappedCount: waypoints.length, routeCoords: [] } + })) + } + const bounds: L.LatLngExpression[] = [] mappedStages.forEach((stage) => { @@ -519,10 +734,16 @@ export default function AdminMissionMap({ const coords = selectedStage ? getStageCoords(selectedStage) : null if (!map || !coords) return - map.flyTo(coords, Math.max(map.getZoom(), 16), { - animate: true, - duration: 0.35, - }) + const currentCenter = map.getCenter() + const targetLatLng = L.latLng(coords) + + // Only fly if it's far away (more than 10 meters) to avoid jumping when dragging + if (currentCenter.distanceTo(targetLatLng) > 10) { + map.flyTo(coords, Math.max(map.getZoom(), 16), { + animate: true, + duration: 0.35, + }) + } }, [selectedStage]) return ( @@ -531,24 +752,69 @@ export default function AdminMissionMap({
-
Mission map
-
{mappedStages.length} mapped nodes
+
Mapa de Misión
+
{mappedStages.length} Nodos Mapeados
- Click en mapa para crear · arrastra pines para mover · click en pin para editar + Haz clic o arrastra la línea roja por el mapa para cambiar la ruta por el monte
-
- - Signal + + {/* Floating High-Contrast Route HUD Widget */} +
+ + 🟢 RUTA SENDEROS + + + 📏 Distancia: {routeMetricsHUD.distanceKm.toFixed(2)} km - - Bearing + | + + ⏱️ Tiempo: {routeMetricsHUD.durationMin >= 60 ? `${Math.floor(routeMetricsHUD.durationMin / 60)}h ${routeMetricsHUD.durationMin % 60}m` : `${routeMetricsHUD.durationMin} min`} - - Circuit + | + + ⛰️ Desnivel: +{routeMetricsHUD.elevationM}m +
+ +
+
diff --git a/frontend/src/admin/components/AdminGameEditor.tsx b/frontend/src/admin/components/AdminGameEditor.tsx index 569df5da..3341d9a5 100644 --- a/frontend/src/admin/components/AdminGameEditor.tsx +++ b/frontend/src/admin/components/AdminGameEditor.tsx @@ -464,7 +464,29 @@ export default function AdminGameEditor({
EDITOR GUIADO -

{title}

+
+ + onPatch({ title: event.target.value })} + placeholder="Escribe el nombre de esta ubicación (ej. Senda Forestal Norte)..." + style={{ + width: '100%', + fontSize: 18, + fontWeight: 800, + background: 'rgba(15, 23, 42, 0.85)', + border: '1.5px solid rgba(251, 191, 36, 0.5)', + borderRadius: 10, + padding: '8px 14px', + color: '#ffffff', + outline: 'none', + boxShadow: '0 2px 8px rgba(0,0,0,0.3)', + }} + /> +
{mode === 'qr' @@ -722,11 +744,11 @@ export default function AdminGameEditor({ onChange={(event) => { const val = event.target.value if (val === 'custom') { - onPatch({ physical_item_id: 'objeto_personalizado', physical_item_label: 'Objeto Personalizado', title: 'Objeto Personalizado', config: { ...config, collectible_purpose: 'standalone' } }) + onPatch({ physical_item_id: 'objeto_personalizado', physical_item_label: 'Objeto Personalizado', config: { ...config, collectible_purpose: 'standalone' } }) } else { const labels: Record = { placa_base: 'Placa base', cables_cobre: 'Cables de cobre', bateria_litio: 'Batería de litio', cinta_aislante: 'Cinta aislante', llave_rota: 'Llave rota' } const purposes: Record = { placa_base: 'crafting', cables_cobre: 'crafting', bateria_litio: 'crafting', cinta_aislante: 'crafting', llave_rota: 'crafting' } - onPatch({ physical_item_id: val, physical_item_label: labels[val], title: labels[val], config: { ...config, collectible_purpose: purposes[val] || 'standalone' } }) + onPatch({ physical_item_id: val, physical_item_label: labels[val], config: { ...config, collectible_purpose: purposes[val] || 'standalone' } }) } }} > diff --git a/frontend/src/admin/components/AdminMissionControlShell.tsx b/frontend/src/admin/components/AdminMissionControlShell.tsx index 3553a4a7..07a7f0f6 100644 --- a/frontend/src/admin/components/AdminMissionControlShell.tsx +++ b/frontend/src/admin/components/AdminMissionControlShell.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import AdminMissionMap from '../AdminMissionMap' import FamiliesPanel from './FamiliesPanel' import NodeDetailDrawer from './NodeDetailDrawer' @@ -206,6 +206,74 @@ export default function AdminMissionControlShell({ (stage) => typeof stage.lat === 'number' && typeof stage.lon === 'number' ).length + const [metrics, setMetrics] = useState({ distanceKm: 0, trailKm: 0, elevationM: 0, durationMin: 0 }) + const [routeCoords, setRouteCoords] = useState<[number, number][]>([]) + + useEffect(() => { + function handleRouteMetrics(event: Event) { + const e = event as CustomEvent + const { distanceKm, elevationM, durationMin, routeCoords: coords } = e.detail + + if (coords && coords.length > 0) { + setRouteCoords(coords) + } else { + const mapped = stages.filter((s) => typeof s.lat === 'number' && typeof s.lon === 'number').sort((a, b) => a.index - b.index) + const straightCoords: [number, number][] = mapped.map((s) => [s.lat!, s.lon!]) + setRouteCoords(straightCoords) + } + + // If OSRM returned 0 (fallback), let's calculate straight distance + if (distanceKm === 0) { + const mapped = stages.filter((s) => typeof s.lat === 'number' && typeof s.lon === 'number').sort((a, b) => a.index - b.index) + let straightKm = 0 + for (let i = 0; i < mapped.length - 1; i++) { + const lat1 = mapped[i].lat! + const lon1 = mapped[i].lon! + const lat2 = mapped[i + 1].lat! + const lon2 = mapped[i + 1].lon! + + const R = 6371.0 + const dlat = ((lat2 - lat1) * Math.PI) / 180 + const dlon = ((lon2 - lon1) * Math.PI) / 180 + const a = Math.sin(dlat / 2) ** 2 + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dlon / 2) ** 2 + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + straightKm += R * c + } + const trailKm = straightKm * 1.3 + setMetrics({ distanceKm: straightKm, trailKm, elevationM: Math.round(straightKm * 48), durationMin: Math.round(trailKm * 15) }) + } else { + // Here distanceKm is already the real trail distance + const trailKm = distanceKm + const calcDur = durationMin || Math.round(trailKm * 15) + setMetrics({ distanceKm: distanceKm / 1.3, trailKm, elevationM, durationMin: calcDur }) + } + } + + window.addEventListener('saga-route-metrics', handleRouteMetrics) + return () => window.removeEventListener('saga-route-metrics', handleRouteMetrics) + }, [stages]) + + function handleExportGpx() { + if (routeCoords.length === 0) return + let gpx = '\n' + gpx += '\n' + gpx += ' \n Ruta SAGA\n \n' + routeCoords.forEach(([lat, lon]) => { + gpx += ` \n` + }) + gpx += ' \n \n' + + const blob = new Blob([gpx], { type: 'application/gpx+xml' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'ruta_saga.gpx' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + function togglePanel(panel: CmsPanel) { onSetCmsPanel(cmsPanel === panel ? 'none' : panel) } @@ -463,11 +531,12 @@ export default function AdminMissionControlShell({ type="button" title="Subir nodo" disabled={routeIndex === 0} - onClick={(event) => { + onPointerDown={(event) => { event.preventDefault() event.stopPropagation() onReorderStage(stage, 'up') }} + onClick={(e) => { e.preventDefault(); e.stopPropagation() }} > ↑ @@ -475,11 +544,12 @@ export default function AdminMissionControlShell({ type="button" title="Bajar nodo" disabled={routeIndex >= stages.length - 1} - onClick={(event) => { + onPointerDown={(event) => { event.preventDefault() event.stopPropagation() onReorderStage(stage, 'down') }} + onClick={(e) => { e.preventDefault(); e.stopPropagation() }} > ↓ @@ -539,6 +609,25 @@ export default function AdminMissionControlShell({ > 📜 Novedades +
@@ -551,6 +640,7 @@ export default function AdminMissionControlShell({
+

- {draft.index + 1}. {draft.title || 'Nodo sin título'} + #{draft.index + 1} + {draft.title ? draft.title.replace(/^\d+\.\s*/, '') : 'Nodo sin título'}

diff --git a/frontend/src/admin/components/PlayersPanel.tsx b/frontend/src/admin/components/PlayersPanel.tsx index 0ebe1d28..b2e980e7 100644 --- a/frontend/src/admin/components/PlayersPanel.tsx +++ b/frontend/src/admin/components/PlayersPanel.tsx @@ -130,7 +130,7 @@ export default function PlayersPanel({ {playerDrafts.map((draft, index) => (
- {t('admin.settingsPanel.prologue')} - {t('admin.settingsPanel.prologueSubtitle')} + 📜 Editor de Prólogo e Historia Inicial + Configura el título, subtítulo e historia del prólogo que ven los jugadores al iniciar la misión
+ +