Interface : corrections à la souris et accès protégé

Onglet « Carte et corrections » : glisser une photo ou une vidéo, placer
les médias sans position, masquer des zones de trace (pour un voyage, ou
zone privée pour tous). Corrections notées dans voyage.toml ([positions],
[corrections]) ou site.toml ([confidentialite]), appliquées par les pages ;
les originaux ne changent pas. L'extraction garde les médias datés sans
position pour qu'on puisse les placer.

Accès : clé d'accès aléatoire à chaque lancement, session par cookie
HttpOnly/SameSite=Strict, mot de passe facultatif (rtg mot-de-passe,
empreinte scrypt hors du site, blocage après 5 échecs), accès réseau
seulement avec mot de passe (--distant), HTTPS possible (--certificat,
--cle), Content-Security-Policy et interdiction des cadres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
This commit is contained in:
FabriceandClaude Opus 5.5 committed 2026-10-09 14:35:43 +00:00
1 parent 06153a227e
commit 71779f6f53
17 files changed
+1047 -38

No files matched your search

+1
View File
@@ -18,6 +18,7 @@
<a href="/site/" target="_blank">Voir le site ↗</a>
</nav>
<div id="bandeau" hx-get="/tache/bandeau" hx-trigger="load, every 2s"></div>
<button class="petit discret-bouton" hx-post="/deconnexion" title="Fermer la session de ce navigateur">Se déconnecter</button>
</header>
<main>
{% block contenu %}{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connexion · RTG</title>
<link rel="stylesheet" href="/static/rtg.css">
<script src="/static/htmx.min.js"></script>
</head>
<body class="page-connexion">
<main class="connexion">
<p class="logo">RTG</p>
{% if connecte %}
<p>Vous êtes connecté.</p>
<p><a class="bouton principal" href="/">Ouvrir l'interface</a></p>
{% else %}
<h1>Accès à l'interface</h1>
{% if not distant %}
<p class="aide">Ouvrez l'adresse complète affichée dans le Terminal où « rtg interface » a été lancé :
elle contient la clé d'accès de cette session{% if mdp %}, ou entrez le mot de passe{% endif %}.</p>
{% endif %}
{% if mdp %}
<form hx-post="/connexion" hx-target="#retour-connexion">
<label>Mot de passe
<input type="password" name="mot_de_passe" autocomplete="current-password" required autofocus>
</label>
<button class="principal">Entrer</button>
<div id="retour-connexion">{% if attente %}<div class="erreur">Trop d'essais : réessayez dans {{ attente }} s.</div>{% endif %}</div>
</form>
{% elif not distant %}
<p class="aide">Pour entrer aussi avec un mot de passe (depuis un autre navigateur ou une autre machine) :
<code>rtg mot-de-passe</code>.</p>
{% endif %}
{% endif %}
</main>
</body>
</html>
+37
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block titre %}{{ v.titre }}{% endblock %}
{% block tete %}<link rel="stylesheet" href="/static/leaflet.css"><script src="/static/leaflet.js"></script>{% endblock %}
{% block fin %}<script src="/static/editeur.js"></script>{% endblock %}
{% block contenu %}
<div class="entete-page">
<div>
@@ -15,6 +16,7 @@
<nav class="onglets">
<a href="#journees">Journées</a>
<a href="#carte">Carte et corrections</a>
<a href="#sources">Sources</a>
<a href="#infos">Infos et récit</a>
<a href="#generer">Générer</a>
@@ -26,6 +28,41 @@
</div>
</section>
<section class="onglet" id="onglet-carte">
<div id="editeur" class="editeur" data-voyage="{{ v.chemin }}">
<aside class="editeur-panneau">
<label>Jour
<select id="ed-jour"><option value="">Tous les jours</option></select>
</label>
<div class="ed-cases">
<label class="choix"><input type="checkbox" id="ed-photos" checked> <span class="mk photo"></span> Photos</label>
<label class="choix"><input type="checkbox" id="ed-videos" checked> <span class="mk video"></span> Vidéos</label>
</div>
<p class="aide">Glissez un marqueur pour corriger sa position ; un clic l'affiche.
<span class="mk photo deplace"></span> = position corrigée à la main.</p>
<div id="ed-mode" class="ed-mode" hidden></div>
<h4>Zones masquées</h4>
<p class="aide">Les points de trace situés dans ces zones sont retirés des pages : autour de chez soi, ou sur une erreur du GPS.</p>
<button id="ed-nouvelle-zone">+ Masquer une zone</button>
<div id="ed-brouillon" class="ed-brouillon" hidden>
<label>Rayon : <strong id="ed-rayon-texte">200 m</strong>
<input type="range" id="ed-rayon" min="20" max="3000" step="10" value="200">
</label>
<label class="choix"><input type="checkbox" id="ed-portee-site"> Zone privée : la retirer de tous les voyages</label>
<p><button class="principal" id="ed-zone-ok">Enregistrer la zone</button> <button id="ed-zone-annuler">Annuler</button></p>
</div>
<ul id="ed-zones" class="ed-liste"></ul>
<h4>Médias sans position <span id="ed-nb-sans" class="compte"></span></h4>
<p class="aide">Cliquez sur un nom puis sur la carte pour le placer.</p>
<ul id="ed-sans" class="ed-liste"></ul>
<p id="ed-etat" class="aide"></p>
</aside>
<div id="ed-carte" class="editeur-carte"><p class="chargement">Chargement de la carte…</p></div>
</div>
</section>
<section class="onglet" id="onglet-sources">
<p class="aide">Indiquez où se trouvent les fichiers d'origine : un ou plusieurs dossiers (ou fichiers) par type, sur n'importe quel disque.
RTG ne les modifie jamais : les traces sont recopiées par journée dans <code>routes/</code>, les photos dans <code>photos/</code> (HEIC converties en JPEG),
+93
View File
@@ -0,0 +1,93 @@
"""Contrôle d'accès à l'interface.
Deux façons d'entrer :
- la clé d'accès (jeton) tirée au hasard à chaque lancement et ajoutée à
l'adresse que RTG ouvre dans le navigateur (et affiche dans le Terminal) :
seul celui qui voit ce Terminal peut entrer ;
- le mot de passe défini par `rtg mot-de-passe`, indispensable pour un accès
depuis une autre machine (`rtg interface --distant`).
Une fois entré, le navigateur garde un cookie de session (HttpOnly,
SameSite=Strict), valable 12 heures après la dernière requête et perdu à
l'arrêt de l'interface. Après 5 mots de passe faux, une adresse IP est
bloquée une minute, puis deux, quatre… jusqu'à une heure.
"""
import hmac
import secrets
import threading
import time
from rtg import acces
DUREE_SESSION = 12 * 3600
ESSAIS_LIBRES = 5
class Gardien:
def __init__(self, port, distant=False, https=False):
self.jeton = secrets.token_urlsafe(24)
self.cookie = f"rtg_{port}"
self.distant, self.https = distant, https
self._sessions = {}
self._echecs = {} # ip → (nombre d'échecs, heure du dernier)
self._verrou = threading.Lock()
# --- sessions
def ouvrir_session(self):
sid = secrets.token_urlsafe(32)
with self._verrou:
self._sessions[sid] = time.time() + DUREE_SESSION
return sid
def session_valide(self, sid):
if not sid:
return False
maintenant = time.time()
with self._verrou:
fin = self._sessions.get(sid)
if not fin or fin < maintenant:
self._sessions.pop(sid, None)
return False
self._sessions[sid] = maintenant + DUREE_SESSION
return True
def fermer_session(self, sid):
with self._verrou:
self._sessions.pop(sid, None)
def entete_cookie(self, sid, effacer=False):
valeur = f"{self.cookie}={'' if effacer else sid}; Path=/; HttpOnly; SameSite=Strict"
if effacer:
valeur += "; Max-Age=0"
if self.https:
valeur += "; Secure"
return valeur
# --- clé d'accès et mot de passe
def jeton_valide(self, jeton):
return bool(jeton) and hmac.compare_digest(jeton.encode(), self.jeton.encode())
def attente(self, ip):
"""Secondes à attendre avant un nouvel essai (0 si libre)."""
with self._verrou:
n, dernier = self._echecs.get(ip, (0, 0))
if n < ESSAIS_LIBRES:
return 0
blocage = min(60 * 2 ** (n - ESSAIS_LIBRES), 3600)
return max(0, int(dernier + blocage - time.time()))
def essayer(self, ip, mdp):
"""Vérifie le mot de passe. Renvoie (ok, message d'erreur)."""
reste = self.attente(ip)
if reste:
return False, f"Trop d'essais : réessayez dans {reste} s."
if not acces.defini():
return False, "Aucun mot de passe n'est défini (commande rtg mot-de-passe)."
if acces.verifier(mdp):
with self._verrou:
self._echecs.pop(ip, None)
return True, ""
with self._verrou:
n, _ = self._echecs.get(ip, (0, 0))
self._echecs[ip] = (n + 1, time.time())
return False, "Mot de passe incorrect."
+199 -19
View File
@@ -1,16 +1,23 @@
"""Interface de RTG : un petit serveur web local, ouvert dans le navigateur.
"""Interface de RTG : un petit serveur web, ouvert dans le navigateur.
Rien n'est installé en plus : le serveur vient de Python, les pages sont des
modèles Jinja2, htmx (échanges avec le serveur) et Leaflet (cartes) sont
fournis dans static/. Le serveur n'écoute que sur 127.0.0.1.
fournis dans static/. Par défaut le serveur n'écoute que sur 127.0.0.1 ;
`--distant` l'ouvre au réseau, avec mot de passe obligatoire.
Sécurité : une page web quelconque ouverte dans le navigateur pourrait
envoyer des requêtes à ce serveur. Toute requête qui modifie quelque chose
doit donc porter l'en-tête HX-Request (qu'un autre site ne peut pas ajouter
sans l'accord du serveur), et l'en-tête Host doit être le nôtre.
Sécurité (voir aussi securite.py) :
- il faut la clé d'accès du lancement ou le mot de passe pour entrer ;
- une page web quelconque ouverte dans le navigateur pourrait envoyer des
requêtes à ce serveur : toute requête qui modifie quelque chose doit donc
porter l'en-tête HX-Request (qu'un autre site ne peut pas ajouter sans
l'accord du serveur) ;
- en local, l'en-tête Host doit être le nôtre (contre le « DNS rebinding ») ;
- les pages de l'interface interdisent leur affichage dans un cadre et
n'exécutent que les scripts servis par RTG (Content-Security-Policy).
"""
import io
import json
import ssl
import mimetypes
import os
import platform
@@ -26,10 +33,11 @@ from urllib.parse import parse_qs, quote, unquote, urlsplit
from jinja2 import Environment, FileSystemLoader, select_autoescape
from rtg import __version__, accueil, site, sources, video
from rtg import __version__, acces, accueil, corrections, site, sources, video
from rtg.config import (QUALITES, SITE_TOML, VOYAGE_TOML, modifier_toml, parametres_video,
reglages_site, reglages_voyage)
from rtg.interface import taches
from rtg.interface.securite import Gardien
from rtg.outils import EXT_PHOTOS, EXT_VIDEOS, est_cache, lister_fichiers, taille_lisible
ICI = os.path.dirname(os.path.abspath(__file__))
@@ -108,6 +116,9 @@ class Application:
("POST", r"/voyage/date", self.voyage_date),
("POST", r"/voyage/dates-suite", self.voyage_dates_suite),
("GET", r"/voyage/infos-utiles", self.voyage_infos_utiles),
("GET", r"/voyage/carte.json", self.voyage_carte),
("POST", r"/voyage/position", self.voyage_position),
("POST", r"/voyage/masque", self.voyage_masque),
("GET", r"/dossiers", self.dossiers),
("GET", r"/reglages", self.reglages),
("POST", r"/reglages", self.reglages_enregistrer),
@@ -260,10 +271,12 @@ class Application:
except OSError:
return (f, None, None)
fichiers = lister_fichiers(cfg["sources"].get("traces", []), (".gpx",), chemin)
autres = [os.path.join(chemin, n) for n in (VOYAGE_TOML, "export_photos.csv", "export_videos.csv")]
autres.append(os.path.join(self.racine, SITE_TOML))
photos = cfg["sources"].get("photos", [])
return tuple(etat(f) for f in fichiers + autres) + (tuple(photos),)
autres = [os.path.join(chemin, n) for n in ("export_photos.csv", "export_videos.csv")]
# Seuls les réglages qui changent l'analyse comptent : déplacer une
# photo sur la carte ne doit pas faire relire toutes les traces.
reglages = json.dumps([cfg["sources"], cfg.get("dates_traces", {}), cfg["journees"],
cfg.get("exclusions", {})], sort_keys=True, default=str)
return tuple(etat(f) for f in fichiers + autres) + (reglages,)
def analyse(self, chemin):
cfg = self.cfg_voyage(chemin)
@@ -341,6 +354,92 @@ class Application:
infos["a_encoder_octets"] = sum(os.path.getsize(f) for f in reste if os.path.exists(f))
return page("_infos_utiles.html", i=infos, cfg=cfg)
# --- Carte et corrections
def _media_json(self, chemin, rel, m):
nom = m["fichier"]
if m["type"] == "photo":
dossier = "photos_web" if os.path.exists(os.path.join(chemin, "photos_web", nom)) else "photos"
vignette = f"/site/{quote(rel)}/{dossier}/{quote(nom)}"
else:
base = os.path.splitext(nom)[0]
ap = os.path.join(chemin, "apercus", base + ".jpg")
vignette = f"/site/{quote(rel)}/apercus/{quote(base)}.jpg" if os.path.exists(ap) else None
d = m["date"][:10].replace(":", "-")
return dict(m, jour=d, heure=m["date"][11:16], vignette=vignette)
def _masques_json(self, cfg):
return {"masques": cfg.get("corrections", {}).get("masques", []),
"zones": cfg.get("confidentialite", {}).get("zones", [])}
def voyage_carte(self, r):
rel = r.q("v")
chemin = self.chemin_voyage(rel)
cfg, a, jours, _ = self.analyse(chemin)
cfg = self.cfg_voyage(chemin) # positions et masques à jour
medias = [self._media_json(chemin, rel, m) for m in corrections.lire_medias(chemin, cfg)]
return json_rep(dict(
self._masques_json(cfg),
couches=[dict(c, jour=j["date"].isoformat()) for c, j in zip(_couches(a, jours), jours)],
jours=self._jours_carte(jours, medias),
medias=medias))
@staticmethod
def _jours_carte(jours, medias):
"""Jours des traces, plus ceux où seuls des médias existent."""
res = {j["date"].isoformat(): {"jour": j["date"].isoformat(), "nom": _date_longue(j["date"])}
for j in jours}
for m in medias:
if m["jour"] not in res:
try:
d = date.fromisoformat(m["jour"])
except ValueError:
continue
res[m["jour"]] = {"jour": m["jour"], "nom": _date_longue(d) + " (sans trace)"}
return [res[k] for k in sorted(res)]
def voyage_position(self, r):
rel = r.f("v")
chemin = self.chemin_voyage(rel)
nom = r.f("fichier")
if not nom:
raise Erreur(400, "Média non précisé")
if r.f("lat") and r.f("lon"):
try:
lat, lon = float(r.f("lat")), float(r.f("lon"))
except ValueError:
raise Erreur(400, "Position invalide")
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
raise Erreur(400, "Position invalide")
self.toml_voyage(chemin, {"positions": {nom: [round(lat, 6), round(lon, 6)]}})
else:
self.toml_voyage(chemin, {"positions": {nom: None}})
cfg = self.cfg_voyage(chemin)
m = next((m for m in corrections.lire_medias(chemin, cfg) if m["fichier"] == nom), None)
return json_rep({"media": self._media_json(chemin, rel, m) if m else None})
def voyage_masque(self, r):
chemin = self.chemin_voyage(r.f("v"))
portee = "site" if r.f("portee") == "site" else "voyage"
cfg = self.cfg_voyage(chemin)
liste = list(cfg["confidentialite"]["zones"] if portee == "site" else cfg["corrections"]["masques"])
if r.f("action") == "ajouter":
try:
lat, lon, rayon = float(r.f("lat")), float(r.f("lon")), int(float(r.f("rayon")))
except ValueError:
raise Erreur(400, "Zone invalide")
liste.append([round(lat, 6), round(lon, 6), max(10, min(rayon, 20000))])
elif r.f("action") == "supprimer":
try:
del liste[int(r.f("index"))]
except (ValueError, IndexError):
raise Erreur(400, "Zone introuvable")
if portee == "site":
modifier_toml(os.path.join(self.racine, SITE_TOML), {"confidentialite": {"zones": liste}},
entete="Réglages du site RoadTripsGenerator (rtg).")
else:
self.toml_voyage(chemin, {"corrections": {"masques": liste}})
return json_rep(self._masques_json(self.cfg_voyage(chemin)))
# --- Navigateur de dossiers
def dossiers(self, r):
chemin = r.q("chemin") or os.path.expanduser("~")
@@ -511,11 +610,21 @@ class Requete:
return _valeur(self.form, k, defaut)
def creer_gestionnaire(app, port):
# Pages de l'interface : scripts et styles de RTG seulement, tuiles des fonds
# de carte, aucun affichage dans un cadre.
CSP = ("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https://tile.openstreetmap.org https://server.arcgisonline.com; "
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
PUBLIQUES = {"/connexion"}
def creer_gestionnaire(app, port, gardien=None):
gardien = gardien or Gardien(port)
hotes = {f"127.0.0.1:{port}", f"localhost:{port}"}
class Gestionnaire(BaseHTTPRequestHandler):
server_version = f"rtg/{__version__}"
sys_version = ""
def log_message(self, fmt, *args):
pass # le terminal sert au journal des traitements
@@ -529,23 +638,59 @@ def creer_gestionnaire(app, port):
def do_POST(self):
self.traiter("POST")
def session(self):
for morceau in (self.headers.get("Cookie") or "").split(";"):
nom, _, valeur = morceau.strip().partition("=")
if nom == gardien.cookie:
return valeur
return ""
def rediriger(self, cible, cookie=None):
self.send_response(303)
self.send_header("Location", cible)
if cookie:
self.send_header("Set-Cookie", cookie)
self.send_header("Content-Length", "0")
self.end_headers()
def traiter(self, methode, tete=False):
try:
if self.headers.get("Host") not in hotes:
if not gardien.distant and self.headers.get("Host") not in hotes:
raise Erreur(403, "Hôte refusé")
url = urlsplit(self.path)
chemin = unquote(url.path)
query = parse_qs(url.query, keep_blank_values=True)
if methode == "GET" and chemin.startswith("/static/"):
return self.fichier(STATIC, chemin[len("/static/"):], tete)
if methode == "GET" and chemin.startswith("/site/"):
return self.fichier(app.racine, chemin[len("/site/"):], tete)
# Adresse ouverte au lancement : la clé ouvre une session, puis
# on la retire de l'adresse (historique, copier-coller).
if "jeton" in query:
if gardien.jeton_valide(_valeur(query, "jeton", "")):
sid = gardien.ouvrir_session()
return self.rediriger(url.path or "/", gardien.entete_cookie(sid))
return self.rediriger("/connexion")
connecte = gardien.session_valide(self.session())
form = {}
if methode == "POST":
if self.headers.get("HX-Request") != "true":
raise Erreur(403, "Requête refusée")
n = int(self.headers.get("Content-Length") or 0)
if n > 2_000_000:
raise Erreur(413, "Requête trop grosse")
form = parse_qs(self.rfile.read(n).decode("utf-8"), keep_blank_values=True)
req = Requete(parse_qs(url.query, keep_blank_values=True), form)
if chemin in PUBLIQUES:
return self.envoyer(self.connexion(methode, form, connecte), tete)
if not connecte:
if self.headers.get("HX-Request") == "true":
return self.envoyer(Reponse("", entetes={"HX-Redirect": "/connexion"}), tete)
return self.rediriger("/connexion")
if chemin == "/deconnexion" and methode == "POST":
gardien.fermer_session(self.session())
return self.envoyer(Reponse("", entetes={
"HX-Redirect": "/connexion", "Set-Cookie": gardien.entete_cookie("", effacer=True)}), tete)
if methode == "GET" and chemin.startswith("/site/"):
return self.fichier(app.racine, chemin[len("/site/"):], tete)
req = Requete(query, form)
for m, motif, fn in app.routes:
if m == methode and motif.match(chemin):
rep = fn(req)
@@ -568,11 +713,26 @@ def creer_gestionnaire(app, port):
rep.entetes = {"HX-Retarget": "#messages", "HX-Reswap": "innerHTML"}
self.envoyer(rep, tete)
def connexion(self, methode, form, connecte):
ip = self.client_address[0]
if methode == "POST":
ok, message = gardien.essayer(ip, _valeur(form, "mot_de_passe", ""))
if ok:
sid = gardien.ouvrir_session()
return Reponse("", entetes={"HX-Redirect": "/", "Set-Cookie": gardien.entete_cookie(sid)})
return Reponse(f'<div class="erreur">{_echapper(message)}</div>')
return page("connexion.html", mdp=acces.defini(), connecte=connecte,
distant=gardien.distant, attente=gardien.attente(ip))
def envoyer(self, rep, tete=False):
self.send_response(rep.code)
self.send_header("Content-Type", rep.type)
self.send_header("Content-Length", str(len(rep.corps)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Content-Security-Policy", CSP)
self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
for k, v in rep.entetes.items():
self.send_header(k, v)
self.end_headers()
@@ -642,21 +802,41 @@ def _echapper(s):
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def lancer(racine, port=8765, ouvrir=True):
def lancer(racine, port=8765, ouvrir=True, distant=False, certificat=None, cle=None):
"""Lance l'interface. distant=True : écoute sur toutes les interfaces
réseau, mot de passe obligatoire ; certificat et cle : HTTPS."""
if distant and not acces.defini():
raise SystemExit("❌ L'accès distant demande un mot de passe : lancez d'abord « rtg mot-de-passe ».")
contexte_tls = None
if certificat:
contexte_tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
contexte_tls.minimum_version = ssl.TLSVersion.TLSv1_2
contexte_tls.load_cert_chain(certificat, cle)
app = Application(racine)
serveur = None
for p in range(port, port + 20):
try:
serveur = ThreadingHTTPServer(("127.0.0.1", p), creer_gestionnaire(app, p))
gardien = Gardien(p, distant=distant, https=bool(contexte_tls))
serveur = ThreadingHTTPServer(("0.0.0.0" if distant else "127.0.0.1", p),
creer_gestionnaire(app, p, gardien))
port = p
break
except OSError:
continue
if serveur is None:
raise SystemExit(f"❌ Aucun port libre entre {port} et {port + 19}.")
if contexte_tls:
serveur.socket = contexte_tls.wrap_socket(serveur.socket, server_side=True)
serveur.daemon_threads = True
url = f"http://127.0.0.1:{port}/"
schema = "https" if contexte_tls else "http"
url = f"{schema}://127.0.0.1:{port}/?jeton={gardien.jeton}"
print(f"🌐 Interface RTG : {url}")
print(" (cette adresse contient la clé d'accès de cette session : ne la partagez pas)")
if distant:
print(f" Accès depuis le réseau : {schema}://<nom ou adresse de cette machine>:{port}/ (mot de passe)")
if not contexte_tls:
print(" ⚠️ Sans HTTPS, le mot de passe circule en clair : placez l'interface derrière votre")
print(" serveur web en HTTPS, utilisez --certificat/--cle, ou un tunnel SSH.")
print(f" Site : {app.racine}")
print(" (Ctrl+C pour arrêter)")
if ouvrir:
+282
View File
@@ -0,0 +1,282 @@
// Onglet « Carte et corrections » : déplacer ou placer les photos et vidéos,
// masquer des zones de trace. Chaque correction est enregistrée aussitôt dans
// voyage.toml (ou site.toml pour une zone privée) ; les pages du site la
// prennent en compte à la génération suivante.
(function () {
"use strict";
const racine = document.getElementById("editeur");
if (!racine || !window.L) return;
const voyage = racine.dataset.voyage;
const $ = id => document.getElementById(id);
let carte = null, donnees = null, coucheTraces, coucheMedias, coucheZones;
let brouillon = null, modeZone = false, aPlacer = null, cadree = false;
// --- Échanges avec le serveur -------------------------------------------
function message(html) {
const m = $("messages");
m.innerHTML = html;
clearTimeout(window._rtgMessage);
window._rtgMessage = setTimeout(() => { m.innerHTML = ""; }, 6000);
}
function lireReponse(r) {
const redirection = r.headers.get("HX-Redirect");
if (redirection) { location.href = redirection; throw new Error("session"); }
if (!(r.headers.get("Content-Type") || "").includes("json")) {
return r.text().then(t => { message(t); throw new Error("refus"); });
}
return r.json();
}
function poster(url, params) {
return fetch(url, {
method: "POST",
headers: { "HX-Request": "true", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(Object.assign({ v: voyage }, params))
}).then(lireReponse);
}
function etat(texte) {
$("ed-etat").textContent = texte;
}
function charger() {
return fetch("/voyage/carte.json?v=" + encodeURIComponent(voyage), { headers: { "HX-Request": "true" } })
.then(lireReponse)
.then(d => { donnees = d; remplirJours(); dessinerTout(); });
}
// --- Carte ----------------------------------------------------------------
function initialiser() {
$("ed-carte").innerHTML = "";
carte = L.map("ed-carte");
const plan = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png",
{ maxZoom: 19, attribution: "© OpenStreetMap" }).addTo(carte);
const satellite = L.tileLayer(
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
{ maxZoom: 19, attribution: "Esri World Imagery" });
L.control.layers({ "Plan": plan, "Satellite": satellite }).addTo(carte);
carte.setView([46.5, 2.5], 5);
coucheTraces = L.featureGroup().addTo(carte);
coucheZones = L.featureGroup().addTo(carte);
coucheMedias = L.featureGroup().addTo(carte);
carte.on("click", clicCarte);
$("ed-jour").addEventListener("change", () => { cadree = false; dessinerTout(); });
$("ed-photos").addEventListener("change", dessinerMedias);
$("ed-videos").addEventListener("change", dessinerMedias);
$("ed-nouvelle-zone").addEventListener("click", commencerZone);
$("ed-zone-annuler").addEventListener("click", finirZone);
$("ed-zone-ok").addEventListener("click", enregistrerZone);
$("ed-rayon").addEventListener("input", () => {
$("ed-rayon-texte").textContent = $("ed-rayon").value + " m";
if (brouillon) brouillon.setRadius(+$("ed-rayon").value);
});
document.addEventListener("keydown", e => {
if (e.key === "Escape") { aPlacer = null; finirZone(); mode(""); }
});
document.body.addEventListener("tache-finie", () => charger());
charger();
}
function remplirJours() {
const choix = $("ed-jour"), actuel = choix.value;
choix.length = 1;
donnees.jours.forEach(j => choix.add(new Option(j.nom, j.jour)));
choix.value = actuel;
}
function jourChoisi() { return $("ed-jour").value; }
function dessinerTout() {
dessinerTraces();
dessinerZones();
dessinerMedias();
if (!cadree) {
const b = coucheTraces.getLayers().length ? coucheTraces.getBounds()
: (coucheMedias.getLayers().length ? coucheMedias.getBounds() : null);
if (b && b.isValid()) carte.fitBounds(b, { padding: [30, 30] });
cadree = true;
}
}
function dessinerTraces() {
coucheTraces.clearLayers();
const jour = jourChoisi();
donnees.couches.forEach(c => {
if (jour && c.jour !== jour) return;
c.segments.forEach(s => L.polyline(s, { color: c.couleur, weight: 4, opacity: 0.85, interactive: false })
.addTo(coucheTraces));
});
}
function icone(m) {
return L.divIcon({ className: "", iconSize: [16, 16], iconAnchor: [8, 8],
html: '<span class="mk ' + m.type + (m.deplace ? " deplace" : "") + '"></span>' });
}
function popup(m) {
const d = document.createElement("div");
d.className = "ed-popup";
if (m.vignette) {
const img = document.createElement("img");
img.src = m.vignette; img.alt = ""; img.loading = "lazy";
d.append(img);
}
const t = document.createElement("div");
t.textContent = m.fichier + " · " + m.jour + " " + m.heure;
d.append(t);
if (m.deplace) {
const b = document.createElement("button");
b.className = "petit";
b.textContent = "Revenir à la position d'origine";
b.addEventListener("click", () => { carte.closePopup(); enregistrerPosition(m.fichier, "", ""); });
d.append(b);
}
return d;
}
function dessinerMedias() {
coucheMedias.clearLayers();
const jour = jourChoisi(), photos = $("ed-photos").checked, videos = $("ed-videos").checked;
const sans = [];
donnees.medias.forEach(m => {
if (jour && m.jour !== jour) return;
if ((m.type === "photo" && !photos) || (m.type === "video" && !videos)) return;
if (m.lat === null) { sans.push(m); return; }
L.marker([m.lat, m.lon], { icon: icone(m), draggable: true, title: m.fichier })
.bindPopup(() => popup(m), { minWidth: 200 })
.on("dragend", e => {
const p = e.target.getLatLng();
enregistrerPosition(m.fichier, p.lat.toFixed(6), p.lng.toFixed(6));
})
.addTo(coucheMedias);
});
const liste = $("ed-sans");
liste.innerHTML = "";
$("ed-nb-sans").textContent = sans.length;
sans.forEach(m => {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "#carte";
a.textContent = (m.type === "photo" ? "📷 " : "🎬 ") + m.fichier;
a.title = m.jour + " " + m.heure;
a.addEventListener("click", e => {
e.preventDefault();
finirZone();
aPlacer = m.fichier;
mode("Cliquez sur la carte pour placer " + m.fichier + " (Échap pour annuler)");
});
const quand = document.createElement("span");
quand.className = "discret";
quand.textContent = " " + m.jour + " " + m.heure;
li.append(a, quand);
liste.append(li);
});
if (!sans.length) liste.innerHTML = '<li class="vide">Aucun</li>';
}
function enregistrerPosition(fichier, lat, lon) {
poster("/voyage/position", { fichier: fichier, lat: lat, lon: lon }).then(r => {
if (r.media) {
const i = donnees.medias.findIndex(m => m.fichier === fichier);
if (i >= 0) donnees.medias[i] = r.media;
}
dessinerMedias();
etat("✓ " + fichier + (lat ? " placé" : " remis à sa position d'origine")
+ ". Régénérez le voyage pour mettre le site à jour.");
}).catch(() => dessinerMedias());
}
// --- Zones masquées -------------------------------------------------------
function dessinerZones() {
coucheZones.clearLayers();
const liste = $("ed-zones");
liste.innerHTML = "";
const ajouter = (z, i, portee) => {
const site = portee === "site";
const cercle = L.circle([z[0], z[1]], {
radius: z[2], color: site ? "#7c3aed" : "#dc2626", weight: 2, fillOpacity: 0.15,
dashArray: site ? "6 6" : null
}).addTo(coucheZones);
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "#carte";
a.textContent = (site ? "Zone privée" : "Zone") + " · " + z[2] + " m";
a.addEventListener("click", e => { e.preventDefault(); carte.fitBounds(cercle.getBounds(), { maxZoom: 16 }); });
const b = document.createElement("button");
b.className = "petit";
b.textContent = "Supprimer";
b.addEventListener("click", () => {
poster("/voyage/masque", { action: "supprimer", portee: portee, index: i }).then(r => {
donnees.masques = r.masques; donnees.zones = r.zones; dessinerZones();
etat("✓ Zone supprimée. Régénérez " + (site ? "les voyages" : "le voyage") + " pour mettre le site à jour.");
});
});
li.append(a, " ", b);
if (site) li.title = "Retirée de tous les voyages (site.toml)";
liste.append(li);
};
donnees.masques.forEach((z, i) => ajouter(z, i, "voyage"));
donnees.zones.forEach((z, i) => ajouter(z, i, "site"));
}
function commencerZone() {
aPlacer = null;
modeZone = true;
mode("Cliquez au centre de la zone à masquer ; un nouveau clic la déplace.");
}
function finirZone() {
modeZone = false;
if (brouillon) { brouillon.remove(); brouillon = null; }
$("ed-brouillon").hidden = true;
if (!aPlacer) mode("");
}
function enregistrerZone() {
if (!brouillon) return;
const c = brouillon.getLatLng(), site = $("ed-portee-site").checked;
poster("/voyage/masque", {
action: "ajouter", portee: site ? "site" : "voyage",
lat: c.lat.toFixed(6), lon: c.lng.toFixed(6), rayon: $("ed-rayon").value
}).then(r => {
donnees.masques = r.masques; donnees.zones = r.zones;
finirZone();
dessinerZones();
etat("✓ Zone enregistrée. Régénérez " + (site ? "les voyages" : "le voyage") + " pour mettre le site à jour.");
});
}
function clicCarte(e) {
if (aPlacer) {
const nom = aPlacer;
aPlacer = null;
mode("");
enregistrerPosition(nom, e.latlng.lat.toFixed(6), e.latlng.lng.toFixed(6));
} else if (modeZone) {
if (!brouillon) {
brouillon = L.circle(e.latlng, { radius: +$("ed-rayon").value, color: "#dc2626", weight: 2,
dashArray: "4 4", fillOpacity: 0.2 }).addTo(carte);
} else {
brouillon.setLatLng(e.latlng);
}
$("ed-brouillon").hidden = false;
mode("Ajustez le rayon puis enregistrez ; un clic sur la carte déplace la zone.");
}
}
function mode(texte) {
const m = $("ed-mode");
m.textContent = texte;
m.hidden = !texte;
$("ed-carte").classList.toggle("en-mode", !!texte);
}
// L'onglet peut être caché au chargement : on prépare la carte à sa première ouverture.
document.addEventListener("rtg:onglet", e => {
if (e.detail !== "carte") return;
if (!carte) initialiser();
else carte.invalidateSize();
});
if (location.hash === "#carte") setTimeout(() => { if (!carte) initialiser(); }, 0);
})();
+40
View File
@@ -145,3 +145,43 @@ progress.global { height: 4px; opacity: .7; }
th, td { padding: 6px 8px; }
.carte { height: 300px; }
}
/* Connexion */
.page-connexion main.connexion { max-width: 420px; margin: 12vh auto; background: var(--carte); border: 1px solid var(--bord);
border-radius: 14px; padding: 24px 28px; }
.connexion .logo { margin: 0 0 8px; }
.connexion label { display: block; margin: 14px 0 10px; font-weight: 500; }
.connexion input[type=password] { display: block; width: 100%; margin-top: 4px; }
.discret-bouton { color: var(--discret); }
/* Carte et corrections */
.editeur { display: grid; grid-template-columns: 300px 1fr; gap: 14px; align-items: start; }
.editeur-panneau { background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 8px 14px 12px;
max-height: 74vh; overflow-y: auto; }
.editeur-panneau h4 { margin: 18px 0 4px; font-size: 14px; }
.editeur-panneau label { display: block; margin: 8px 0; }
.editeur-panneau select { width: 100%; margin-top: 4px; }
.editeur-panneau input[type=range] { width: 100%; }
.ed-cases { display: flex; gap: 16px; }
.editeur-carte { height: 74vh; border-radius: 10px; border: 1px solid var(--bord); }
.editeur-carte.en-mode { cursor: crosshair; outline: 3px solid var(--accent); }
.editeur-carte.en-mode .leaflet-container, .editeur-carte.en-mode.leaflet-container { cursor: crosshair; }
.ed-mode { background: var(--attention-fond); color: var(--attention); padding: 6px 10px; border-radius: 8px; font-size: 13px; margin: 8px 0; }
.ed-brouillon { background: var(--neutre-fond); border-radius: 8px; padding: 4px 10px; margin: 8px 0; }
.ed-liste { list-style: none; padding: 0; margin: 4px 0; font-size: 13px; }
.ed-liste li { padding: 3px 0; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.ed-liste a { text-decoration: none; overflow-wrap: anywhere; }
.mk { display: inline-block; width: 14px; height: 14px; border-radius: 50%; border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0,0,0,.35); vertical-align: middle; }
.mk.photo { background: #ef4444; }
.mk.video { background: #2563eb; }
.mk.deplace { border-color: #f59e0b; box-shadow: 0 0 0 2px #f59e0b; }
.leaflet-marker-draggable .mk { cursor: grab; }
.ed-popup { max-width: 240px; font-size: 12px; }
.ed-popup img { display: block; max-width: 240px; max-height: 180px; border-radius: 6px; margin-bottom: 4px; }
.ed-popup button { margin-top: 6px; }
@media (max-width: 800px) {
.editeur { grid-template-columns: 1fr; }
.editeur-panneau { max-height: none; }
.editeur-carte { height: 60vh; }
}
+1
View File
@@ -18,6 +18,7 @@
s.classList.toggle("actif", s.id === "onglet-" + (trouve ? nom : liens[0].getAttribute("href").slice(1)));
});
document.querySelectorAll(".onglet.actif .carte").forEach(cadrer);
document.dispatchEvent(new CustomEvent("rtg:onglet", { detail: trouve ? nom : "" }));
}
window.addEventListener("hashchange", afficherOnglet);
document.addEventListener("DOMContentLoaded", afficherOnglet);