Implement i18next

This commit is contained in:
Marcus Kida 2026-02-14 16:01:40 +01:00
parent 96f4f3b8e4
commit 0f7e3dee1e
11 changed files with 270 additions and 49 deletions

2
db.go
View file

@ -41,7 +41,7 @@ func (a *StringArray) Scan(src interface{}) error {
} }
func (a StringArray) Value() (driver.Value, error) { func (a StringArray) Value() (driver.Value, error) {
if a == nil || len(a) == 0 { if len(a) == 0 {
return "{}", nil return "{}", nil
} }
elems := make([]string, len(a)) elems := make([]string, len(a))

View file

@ -97,7 +97,7 @@
} }
acTimer = setTimeout(function () { acTimer = setTimeout(function () {
acController = new AbortController(); acController = new AbortController();
listEl.innerHTML = '<li class="ac-loading"><span class="spinner"></span>Searching...</li>'; listEl.innerHTML = '<li class="ac-loading"><span class="spinner"></span>' + t("searching") + '</li>';
listEl.classList.add("open"); listEl.classList.add("open");
fetch( fetch(
"https://nominatim.openstreetmap.org/search?" + "https://nominatim.openstreetmap.org/search?" +
@ -106,7 +106,7 @@
format: "json", format: "json",
limit: "5", limit: "5",
addressdetails: "0", addressdetails: "0",
"accept-language": "de", "accept-language": getLocale(),
}), }),
{ signal: acController.signal } { signal: acController.signal }
) )
@ -234,7 +234,7 @@
} }
function showCount(count) { function showCount(count) {
countEl.textContent = count + " repeater" + (count !== 1 ? "s" : ""); countEl.textContent = t("repeater_count", { count: count });
} }
function showStatus(msg) { function showStatus(msg) {
@ -247,50 +247,50 @@
var html = '<div class="rptr-popup">'; var html = '<div class="rptr-popup">';
html += '<h3><a href="https://brandmeister.network/?page=repeater&id=' + r.id + '" target="_blank" rel="noopener">' + escapeHtml(r.callsign) + '</a> <span class="band-tag ' + bandClass + '">' + escapeHtml(r.band) + "</span></h3>"; html += '<h3><a href="https://brandmeister.network/?page=repeater&id=' + r.id + '" target="_blank" rel="noopener">' + escapeHtml(r.callsign) + '</a> <span class="band-tag ' + bandClass + '">' + escapeHtml(r.band) + "</span></h3>";
html += "<table>"; html += "<table>";
html += "<tr><td>TX</td><td>" + r.freq_tx.toFixed(4) + " MHz</td></tr>"; html += "<tr><td>" + t("popup_tx") + "</td><td>" + r.freq_tx.toFixed(4) + " MHz</td></tr>";
if (r.freq_rx) if (r.freq_rx)
html += "<tr><td>RX</td><td>" + r.freq_rx.toFixed(4) + " MHz</td></tr>"; html += "<tr><td>" + t("popup_rx") + "</td><td>" + r.freq_rx.toFixed(4) + " MHz</td></tr>";
if (r.freq_offset) if (r.freq_offset)
html += html +=
"<tr><td>Offset</td><td>" + "<tr><td>" + t("popup_offset") + "</td><td>" +
escapeHtml(r.freq_offset) + escapeHtml(r.freq_offset) +
" MHz</td></tr>"; " MHz</td></tr>";
html += "<tr><td>CC</td><td>" + r.color_code + "</td></tr>"; html += "<tr><td>" + t("popup_cc") + "</td><td>" + r.color_code + "</td></tr>";
var loc = escapeHtml(r.city); var loc = escapeHtml(r.city);
if (r.state) loc += ", " + escapeHtml(r.state); if (r.state) loc += ", " + escapeHtml(r.state);
if (r.country) loc += ", " + escapeHtml(r.country); if (r.country) loc += ", " + escapeHtml(r.country);
html += "<tr><td>Location</td><td>" + loc + "</td></tr>"; html += "<tr><td>" + t("popup_location") + "</td><td>" + loc + "</td></tr>";
if (r.networks && r.networks.length) if (r.networks && r.networks.length)
html += html +=
"<tr><td>Network</td><td>" + "<tr><td>" + t("popup_network") + "</td><td>" +
r.networks.map(escapeHtml).join(", ") + r.networks.map(escapeHtml).join(", ") +
"</td></tr>"; "</td></tr>";
if (r.trustee) if (r.trustee)
html += html +=
"<tr><td>Trustee</td><td>" + "<tr><td>" + t("popup_trustee") + "</td><td>" +
escapeHtml(r.trustee) + escapeHtml(r.trustee) +
"</td></tr>"; "</td></tr>";
if (r.ts_linked) if (r.ts_linked)
html += html +=
"<tr><td>Timeslots</td><td>" + "<tr><td>" + t("popup_timeslots") + "</td><td>" +
escapeHtml(r.ts_linked) + escapeHtml(r.ts_linked) +
"</td></tr>"; "</td></tr>";
html += html +=
"<tr><td>Status</td><td>" + "<tr><td>" + t("popup_status") + "</td><td>" +
escapeHtml(r.status) + escapeHtml(r.status) +
"</td></tr>"; "</td></tr>";
if (r.bm_status_text) if (r.bm_status_text)
html += "<tr><td>BM Status</td><td>" + escapeHtml(r.bm_status_text) + "</td></tr>"; html += "<tr><td>" + t("popup_bm_status") + "</td><td>" + escapeHtml(r.bm_status_text) + "</td></tr>";
if (r.last_seen) if (r.last_seen)
html += "<tr><td>Last seen</td><td>" + escapeHtml(r.last_seen.replace("T", " ").substring(0, 19)) + "</td></tr>"; html += "<tr><td>" + t("popup_last_seen") + "</td><td>" + escapeHtml(r.last_seen.replace("T", " ").substring(0, 19)) + "</td></tr>";
if (r.hardware) if (r.hardware)
html += "<tr><td>Hardware</td><td>" + escapeHtml(r.hardware) + "</td></tr>"; html += "<tr><td>" + t("popup_hardware") + "</td><td>" + escapeHtml(r.hardware) + "</td></tr>";
if (r.pep) if (r.pep)
html += "<tr><td>Power</td><td>" + r.pep + " W</td></tr>"; html += "<tr><td>" + t("popup_power") + "</td><td>" + r.pep + " W</td></tr>";
if (r.agl) if (r.agl)
html += "<tr><td>Antenna</td><td>" + r.agl + " m AGL</td></tr>"; html += "<tr><td>" + t("popup_antenna") + "</td><td>" + r.agl + " m AGL</td></tr>";
if (r.inactive) if (r.inactive)
html += '<tr><td></td><td style="color:#ef5350;font-weight:600">Inactive</td></tr>'; html += '<tr><td></td><td style="color:#ef5350;font-weight:600">' + t("popup_inactive") + '</td></tr>';
html += "</table></div>"; html += "</table></div>";
return html; return html;
} }
@ -357,7 +357,7 @@
.catch(function (err) { .catch(function (err) {
if (err.name === "AbortError") return; if (err.name === "AbortError") return;
console.error("Fetch error:", err); console.error("Fetch error:", err);
showStatus("Error"); showStatus(t("error"));
}); });
} }
@ -369,7 +369,7 @@
q: address, q: address,
format: "json", format: "json",
limit: "1", limit: "1",
"accept-language": "de", "accept-language": getLocale(),
}) })
) )
.then(function (resp) { .then(function (resp) {
@ -414,7 +414,7 @@
return; return;
} }
showStatus("Loading..."); showStatus(t("loading"));
return fetch("/api/repeaters/route", { return fetch("/api/repeaters/route", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -440,7 +440,7 @@
}) })
.catch(function (err) { .catch(function (err) {
console.error("Route fetch error:", err); console.error("Route fetch error:", err);
showStatus("Error"); showStatus(t("error"));
}); });
} }
@ -522,8 +522,8 @@
if (isPinMode) clearPin(); if (isPinMode) clearPin();
if (isSearchMode) clearSearch(); if (isSearchMode) clearSearch();
routeBtn.disabled = true; routeBtn.disabled = true;
routeBtn.innerHTML = '<span class="spinner"></span>Routing...'; routeBtn.innerHTML = '<span class="spinner"></span>' + t("routing");
showStatus("Geocoding..."); showStatus(t("geocoding"));
geocode(fromAddr) geocode(fromAddr)
.then(function (from) { .then(function (from) {
@ -532,7 +532,7 @@
}); });
}) })
.then(function (endpoints) { .then(function (endpoints) {
showStatus("Routing..."); showStatus(t("routing"));
return getRoute(endpoints.from, endpoints.to); return getRoute(endpoints.from, endpoints.to);
}) })
.then(function (latLngs) { .then(function (latLngs) {
@ -560,7 +560,7 @@
}) })
.then(function () { .then(function () {
routeBtn.disabled = false; routeBtn.disabled = false;
routeBtn.textContent = "Route"; routeBtn.textContent = t("route_btn");
}); });
} }
@ -596,7 +596,7 @@
return; return;
} }
showStatus("Loading..."); showStatus(t("loading"));
var params = new URLSearchParams({ var params = new URLSearchParams({
lat: pinLatLng.lat, lat: pinLatLng.lat,
lng: pinLatLng.lng, lng: pinLatLng.lng,
@ -619,7 +619,7 @@
}) })
.catch(function (err) { .catch(function (err) {
console.error("Pin fetch error:", err); console.error("Pin fetch error:", err);
showStatus("Error"); showStatus(t("error"));
}); });
} }
@ -912,7 +912,7 @@
if (searchController) searchController.abort(); if (searchController) searchController.abort();
searchController = new AbortController(); searchController = new AbortController();
showStatus("Searching..."); showStatus(t("searching"));
fetch("/api/repeaters/search?q=" + encodeURIComponent(q), { signal: searchController.signal }) fetch("/api/repeaters/search?q=" + encodeURIComponent(q), { signal: searchController.signal })
.then(function (resp) { .then(function (resp) {
@ -939,7 +939,7 @@
if (data.total > data.count) { if (data.total > data.count) {
var msg = document.createElement("div"); var msg = document.createElement("div");
msg.className = "search-more-msg"; msg.className = "search-more-msg";
msg.textContent = "Showing " + data.count + " of " + data.total + " results"; msg.textContent = t("showing_results", { count: data.count, total: data.total });
searchListEl.appendChild(msg); searchListEl.appendChild(msg);
} }
searchListEl.style.display = ""; searchListEl.style.display = "";
@ -947,7 +947,7 @@
.catch(function (err) { .catch(function (err) {
if (err.name === "AbortError") return; if (err.name === "AbortError") return;
console.error("Search error:", err); console.error("Search error:", err);
showStatus("Error"); showStatus(t("error"));
}); });
} }
@ -1007,9 +1007,17 @@
window.addEventListener("hashchange", checkHash); window.addEventListener("hashchange", checkHash);
// === Init === // === Init ===
function initApp() {
if (window.location.hash) { if (window.location.hash) {
checkHash(); checkHash();
} else { } else {
fetchRepeaters(); fetchRepeaters();
} }
}
if (i18next.isInitialized) {
initApp();
} else {
document.addEventListener("i18n-ready", initApp);
}
})(); })();

38
static/i18n.js Normal file
View file

@ -0,0 +1,38 @@
var SUPPORTED_LANGS = ["en", "de", "es", "fr"];
function translateDOM() {
document.querySelectorAll("[data-i18n]").forEach(function (el) {
el.textContent = i18next.t(el.getAttribute("data-i18n"));
});
document.querySelectorAll("[data-i18n-placeholder]").forEach(function (el) {
el.placeholder = i18next.t(el.getAttribute("data-i18n-placeholder"));
});
}
function t(key, opts) {
return i18next.t(key, opts);
}
function getLocale() {
return i18next.resolvedLanguage || "en";
}
i18next
.use(i18nextBrowserLanguageDetector)
.use(i18nextHttpBackend)
.init({
supportedLngs: SUPPORTED_LANGS,
fallbackLng: "en",
detection: {
order: ["querystring", "navigator"],
lookupQuerystring: "lang",
caches: [],
},
backend: {
loadPath: "/locales/{{lng}}.json",
},
}, function () {
document.documentElement.lang = getLocale();
translateDOM();
document.dispatchEvent(new Event("i18n-ready"));
});

View file

@ -16,39 +16,39 @@
<span id="count" class="count"></span> <span id="count" class="count"></span>
</div> </div>
<div class="controls-row search-row"> <div class="controls-row search-row">
<input type="text" id="search-input" placeholder="Search callsign, city, ID..." autocomplete="off" /> <input type="text" id="search-input" data-i18n-placeholder="search_placeholder" placeholder="Search callsign, city, ID..." autocomplete="off" />
<button type="button" id="search-clear" class="search-clear-btn" style="display:none">&times;</button> <button type="button" id="search-clear" class="search-clear-btn" style="display:none">&times;</button>
</div> </div>
<div id="search-list" class="pin-list" style="display:none"></div> <div id="search-list" class="pin-list" style="display:none"></div>
<details class="advanced"> <details class="advanced">
<summary>Filters</summary> <summary data-i18n="filters">Filters</summary>
<div class="controls-row network-row"> <div class="controls-row network-row">
<label class="net-label net-bm"><input type="checkbox" id="net-bm" checked> BM</label> <label class="net-label net-bm"><input type="checkbox" id="net-bm" checked> BM</label>
<label class="net-label net-dmrplus"><input type="checkbox" id="net-dmrplus"> DMR+</label> <label class="net-label net-dmrplus"><input type="checkbox" id="net-dmrplus"> DMR+</label>
<label class="net-label net-tgif"><input type="checkbox" id="net-tgif"> TGIF</label> <label class="net-label net-tgif"><input type="checkbox" id="net-tgif"> TGIF</label>
<label class="net-label net-other"><input type="checkbox" id="net-other"> Other</label> <label class="net-label net-other"><input type="checkbox" id="net-other"> Other</label>
</div> </div>
<label class="hotspot-label"><input type="checkbox" id="show-hotspots"> Show personal hotspots</label> <label class="hotspot-label"><input type="checkbox" id="show-hotspots"> <span data-i18n="show_hotspots">Show personal hotspots</span></label>
<label class="hotspot-label"><input type="checkbox" id="show-inactive"> Show inactive repeaters</label> <label class="hotspot-label"><input type="checkbox" id="show-inactive"> <span data-i18n="show_inactive">Show inactive repeaters</span></label>
</details> </details>
<details class="experimental"> <details class="experimental">
<summary>Experimental</summary> <summary data-i18n="experimental">Experimental</summary>
<label class="hotspot-label"><input type="checkbox" id="show-heatmap"> Visualize Last Heard Heatmap</label> <label class="hotspot-label"><input type="checkbox" id="show-heatmap"> <span data-i18n="show_heatmap">Visualize Last Heard Heatmap</span></label>
</details> </details>
<div class="controls-row route-row autocomplete-wrap"> <div class="controls-row route-row autocomplete-wrap">
<input type="text" id="route-from" placeholder="From address" autocomplete="off" /> <input type="text" id="route-from" data-i18n-placeholder="from_address" placeholder="From address" autocomplete="off" />
<ul class="autocomplete-list" id="ac-from"></ul> <ul class="autocomplete-list" id="ac-from"></ul>
</div> </div>
<div class="controls-row route-row autocomplete-wrap"> <div class="controls-row route-row autocomplete-wrap">
<input type="text" id="route-to" placeholder="To address" autocomplete="off" /> <input type="text" id="route-to" data-i18n-placeholder="to_address" placeholder="To address" autocomplete="off" />
<ul class="autocomplete-list" id="ac-to"></ul> <ul class="autocomplete-list" id="ac-to"></ul>
</div> </div>
<div class="controls-row route-row"> <div class="controls-row route-row">
<button id="route-btn" disabled>Route</button> <button id="route-btn" data-i18n="route_btn" disabled>Route</button>
<button id="clear-btn">Clear</button> <button id="clear-btn" data-i18n="clear_btn">Clear</button>
</div> </div>
<div class="controls-row route-row corridor-row" id="corridor-row" style="display:none"> <div class="controls-row route-row corridor-row" id="corridor-row" style="display:none">
<label class="corridor-label">Corridor <label class="corridor-label"><span data-i18n="corridor">Corridor</span>
<input type="range" id="corridor-range" min="1" max="50" value="10" /> <input type="range" id="corridor-range" min="1" max="50" value="10" />
<span id="corridor-val">10</span> km <span id="corridor-val">10</span> km
</label> </label>
@ -56,11 +56,11 @@
<div id="route-list" class="pin-list" style="display:none"></div> <div id="route-list" class="pin-list" style="display:none"></div>
<div id="pin-controls" class="pin-controls" style="display:none"> <div id="pin-controls" class="pin-controls" style="display:none">
<div class="controls-row pin-header"> <div class="controls-row pin-header">
<label class="pin-radius-label">Radius <label class="pin-radius-label"><span data-i18n="radius">Radius</span>
<input type="range" id="pin-radius" min="10" max="200" value="50" step="10" /> <input type="range" id="pin-radius" min="10" max="200" value="50" step="10" />
<span id="pin-radius-val">50</span> km <span id="pin-radius-val">50</span> km
</label> </label>
<button id="pin-clear" class="pin-clear-btn">Clear pin</button> <button id="pin-clear" class="pin-clear-btn" data-i18n="clear_pin">Clear pin</button>
</div> </div>
<div id="pin-list" class="pin-list"></div> <div id="pin-list" class="pin-list"></div>
</div> </div>
@ -73,9 +73,13 @@
Routing by <a href="https://project-osrm.org" target="_blank" rel="noopener">OSRM</a>. Routing by <a href="https://project-osrm.org" target="_blank" rel="noopener">OSRM</a>.
Map by <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>. Map by <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>.
Built by <a rel="me" href="https://social.darc.de/@DA6KM" target="_blank" rel="noopener">DA6KM</a>.<br> Built by <a rel="me" href="https://social.darc.de/@DA6KM" target="_blank" rel="noopener">DA6KM</a>.<br>
<a href="/impressum.html">Impressum</a> · <a href="/datenschutz.html">Datenschutz</a> · <a href="https://git.kida.io/marcus/DMRmap" target="_blank" rel="noopener">Source</a><br> <a href="/impressum.html" id="link-impressum" data-i18n="impressum">Impressum</a> · <a href="/datenschutz.html" id="link-datenschutz" data-i18n="datenschutz">Datenschutz</a> · <a href="https://git.kida.io/marcus/DMRmap" target="_blank" rel="noopener" data-i18n="source">Source</a><br>
</div> </div>
</div> </div>
<script src="lib/i18next.min.js"></script>
<script src="lib/i18next-browser-languagedetector.min.js"></script>
<script src="lib/i18next-http-backend.min.js"></script>
<script src="i18n.js"></script>
<script src="lib/leaflet.js"></script> <script src="lib/leaflet.js"></script>
<script src="lib/socket.io.min.js"></script> <script src="lib/socket.io.min.js"></script>
<script src="app.js"></script> <script src="app.js"></script>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
static/lib/i18next.min.js vendored Normal file

File diff suppressed because one or more lines are too long

42
static/locales/de.json Normal file
View file

@ -0,0 +1,42 @@
{
"search_placeholder": "Rufzeichen, Stadt, ID suchen...",
"filters": "Filter",
"show_hotspots": "Persönliche Hotspots anzeigen",
"show_inactive": "Inaktive Relais anzeigen",
"experimental": "Experimentell",
"show_heatmap": "Last Heard Heatmap anzeigen",
"from_address": "Startadresse",
"to_address": "Zieladresse",
"route_btn": "Route",
"clear_btn": "Löschen",
"corridor": "Korridor",
"radius": "Radius",
"clear_pin": "Pin löschen",
"repeater_count_one": "{{count}} Relais",
"repeater_count_other": "{{count}} Relais",
"loading": "Laden...",
"searching": "Suche...",
"geocoding": "Geocodierung...",
"routing": "Routenberechnung...",
"error": "Fehler",
"showing_results": "{{count}} von {{total}} Ergebnissen",
"popup_tx": "TX",
"popup_rx": "RX",
"popup_offset": "Offset",
"popup_cc": "CC",
"popup_location": "Standort",
"popup_network": "Netzwerk",
"popup_trustee": "Treuhänder",
"popup_timeslots": "Zeitschlitze",
"popup_status": "Status",
"popup_bm_status": "BM Status",
"popup_last_seen": "Zuletzt gesehen",
"popup_hardware": "Hardware",
"popup_power": "Leistung",
"popup_antenna": "Antenne",
"popup_inactive": "Inaktiv",
"back_to_map": "Zur Karte",
"impressum": "Impressum",
"datenschutz": "Datenschutz",
"source": "Quellcode"
}

42
static/locales/en.json Normal file
View file

@ -0,0 +1,42 @@
{
"search_placeholder": "Search callsign, city, ID...",
"filters": "Filters",
"show_hotspots": "Show personal hotspots",
"show_inactive": "Show inactive repeaters",
"experimental": "Experimental",
"show_heatmap": "Visualize Last Heard Heatmap",
"from_address": "From address",
"to_address": "To address",
"route_btn": "Route",
"clear_btn": "Clear",
"corridor": "Corridor",
"radius": "Radius",
"clear_pin": "Clear pin",
"repeater_count_one": "{{count}} repeater",
"repeater_count_other": "{{count}} repeaters",
"loading": "Loading...",
"searching": "Searching...",
"geocoding": "Geocoding...",
"routing": "Routing...",
"error": "Error",
"showing_results": "Showing {{count}} of {{total}} results",
"popup_tx": "TX",
"popup_rx": "RX",
"popup_offset": "Offset",
"popup_cc": "CC",
"popup_location": "Location",
"popup_network": "Network",
"popup_trustee": "Trustee",
"popup_timeslots": "Timeslots",
"popup_status": "Status",
"popup_bm_status": "BM Status",
"popup_last_seen": "Last seen",
"popup_hardware": "Hardware",
"popup_power": "Power",
"popup_antenna": "Antenna",
"popup_inactive": "Inactive",
"back_to_map": "Back to map",
"impressum": "Legal Notice",
"datenschutz": "Privacy Policy",
"source": "Source"
}

42
static/locales/es.json Normal file
View file

@ -0,0 +1,42 @@
{
"search_placeholder": "Buscar indicativo, ciudad, ID...",
"filters": "Filtros",
"show_hotspots": "Mostrar hotspots personales",
"show_inactive": "Mostrar repetidores inactivos",
"experimental": "Experimental",
"show_heatmap": "Visualizar mapa de calor Last Heard",
"from_address": "Dirección de origen",
"to_address": "Dirección de destino",
"route_btn": "Ruta",
"clear_btn": "Borrar",
"corridor": "Corredor",
"radius": "Radio",
"clear_pin": "Borrar marcador",
"repeater_count_one": "{{count}} repetidor",
"repeater_count_other": "{{count}} repetidores",
"loading": "Cargando...",
"searching": "Buscando...",
"geocoding": "Geocodificando...",
"routing": "Calculando ruta...",
"error": "Error",
"showing_results": "Mostrando {{count}} de {{total}} resultados",
"popup_tx": "TX",
"popup_rx": "RX",
"popup_offset": "Offset",
"popup_cc": "CC",
"popup_location": "Ubicación",
"popup_network": "Red",
"popup_trustee": "Responsable",
"popup_timeslots": "Intervalos de tiempo",
"popup_status": "Estado",
"popup_bm_status": "Estado BM",
"popup_last_seen": "Última vez visto",
"popup_hardware": "Hardware",
"popup_power": "Potencia",
"popup_antenna": "Antena",
"popup_inactive": "Inactivo",
"back_to_map": "Volver al mapa",
"impressum": "Aviso legal",
"datenschutz": "Política de privacidad",
"source": "Código fuente"
}

42
static/locales/fr.json Normal file
View file

@ -0,0 +1,42 @@
{
"search_placeholder": "Rechercher indicatif, ville, ID...",
"filters": "Filtres",
"show_hotspots": "Afficher les hotspots personnels",
"show_inactive": "Afficher les relais inactifs",
"experimental": "Expérimental",
"show_heatmap": "Visualiser la carte thermique Last Heard",
"from_address": "Adresse de départ",
"to_address": "Adresse d'arrivée",
"route_btn": "Itinéraire",
"clear_btn": "Effacer",
"corridor": "Corridor",
"radius": "Rayon",
"clear_pin": "Supprimer le marqueur",
"repeater_count_one": "{{count}} relais",
"repeater_count_other": "{{count}} relais",
"loading": "Chargement...",
"searching": "Recherche...",
"geocoding": "Géocodage...",
"routing": "Calcul de l'itinéraire...",
"error": "Erreur",
"showing_results": "Affichage de {{count}} sur {{total}} résultats",
"popup_tx": "TX",
"popup_rx": "RX",
"popup_offset": "Offset",
"popup_cc": "CC",
"popup_location": "Emplacement",
"popup_network": "Réseau",
"popup_trustee": "Responsable",
"popup_timeslots": "Créneaux horaires",
"popup_status": "Statut",
"popup_bm_status": "Statut BM",
"popup_last_seen": "Dernière activité",
"popup_hardware": "Matériel",
"popup_power": "Puissance",
"popup_antenna": "Antenne",
"popup_inactive": "Inactif",
"back_to_map": "Retour à la carte",
"impressum": "Mentions légales",
"datenschutz": "Politique de confidentialité",
"source": "Code source"
}