server-verwaltung/app/templates/servers_map.html
nocci cc7c75ba33 🌐 i18n(i18n): add multilingual support with translations
- create i18n.py for managing translations and resolving locale
- add German and English translations for various UI components
- integrate translation functions into templates for dynamic language support
2025-12-06 13:58:46 +00:00

228 lines
6.7 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block extra_head %}
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
/>
<style>
/* Make Leaflet popups look a bit nicer with Tailwind-ish typography */
.leaflet-popup-content-wrapper {
background-color: #020617;
color: #e5e7eb;
border-radius: 0.75rem;
border: 1px solid #1f2937;
}
.leaflet-popup-tip {
background-color: #020617;
}
</style>
{% endblock %}
{% block content %}
<div class="flex flex-col gap-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold tracking-tight">{{ t("map.title") }}</h1>
<p class="text-xs text-slate-400">
{{ t("map.subtitle") }}
</p>
</div>
</div>
<!-- Hidden list of servers passed via data attributes to JS -->
<ul id="server-data" class="hidden">
{% for s in servers %}
<li
data-id="{{ s.id }}"
data-name="{{ s.name }}"
data-provider="{{ s.provider }}"
data-ipv4="{{ s.ipv4 or '' }}"
data-location="{{ s.location or '' }}"
></li>
{% endfor %}
</ul>
<div
id="map"
class="w-full h-[520px] rounded-xl border border-slate-800 bg-slate-900/70"
></div>
<p class="text-[11px] text-slate-500">
{{ t("map.note") }}
</p>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// Basic HTML escaping to avoid XSS when injecting text into popups
function escapeHtml(str) {
return String(str || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Very small built-in city coordinate map (approximate, not exhaustive).
// Keys are lowercase.
const CITY_COORDS = {
"berlin": [52.52, 13.405],
"frankfurt": [50.1109, 8.6821],
"nuremberg": [49.4521, 11.0767],
"nürnberg": [49.4521, 11.0767],
"falkenstein": [50.474, 12.371],
"helsinki": [60.1699, 24.9384],
"amsterdam": [52.3676, 4.9041],
"paris": [48.8566, 2.3522],
"london": [51.5074, -0.1278],
"vienna": [48.2082, 16.3738],
"wien": [48.2082, 16.3738],
"zurich": [47.3769, 8.5417],
"zürich": [47.3769, 8.5417],
"stockholm": [59.3293, 18.0686],
"prague": [50.0755, 14.4378],
"praha": [50.0755, 14.4378],
"warsaw": [52.2297, 21.0122],
"madrid": [40.4168, -3.7038],
"rome": [41.9028, 12.4964],
"milano": [45.4642, 9.19],
"new york": [40.7128, -74.006],
"nyc": [40.7128, -74.006],
"chicago": [41.8781, -87.6298],
"ireland": [53.3498, -6.2603], // Dublin
"irland": [53.3498, -6.2603],
"dublin": [53.3498, -6.2603],
"romania": [44.4268, 26.1025], // Bucharest
"rumänien": [44.4268, 26.1025],
"bucharest": [44.4268, 26.1025],
"bucuresti": [44.4268, 26.1025],
"bucurești": [44.4268, 26.1025],
"moldova": [47.0105, 28.8638], // Chișinău
"moldau": [47.0105, 28.8638],
"chisinau": [47.0105, 28.8638],
"chișinău": [47.0105, 28.8638],
"ashburn": [39.0438, -77.4874]
};
// Deterministic string hash → used for pseudo coordinates for unknown locations
function hashString(str) {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = (h << 5) - h + str.charCodeAt(i);
h |= 0; // force 32-bit
}
return h;
}
function getBaseCoords(locationKey) {
const key = locationKey.toLowerCase();
if (Object.prototype.hasOwnProperty.call(CITY_COORDS, key)) {
return CITY_COORDS[key];
}
// Fuzzy match: if the city keyword appears in the location string
for (const cityKey in CITY_COORDS) {
if (key.includes(cityKey)) {
return CITY_COORDS[cityKey];
}
}
return null;
}
document.addEventListener("DOMContentLoaded", () => {
const list = document.querySelectorAll("#server-data li");
const servers = Array.from(list).map((el) => ({
id: parseInt(el.dataset.id, 10),
name: el.dataset.name || "",
provider: el.dataset.provider || "",
ipv4: el.dataset.ipv4 || "",
location: el.dataset.location || ""
}));
const map = L.map("map", {
worldCopyJump: true,
scrollWheelZoom: true,
zoomSnap: 0.25
}).setView([20, 0], 2);
// Simple OpenStreetMap tiles
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
}).addTo(map);
const markers = [];
const locationCounts = {};
// Create markers; approximate coordinates based on location string
for (const s of servers) {
const rawLoc = (s.location || "").trim();
if (!rawLoc) {
// no location → skip from map
continue;
}
const key = rawLoc.toLowerCase();
// Base coordinates: either from CITY_COORDS or a deterministic fallback
let base = getBaseCoords(key);
if (!base) {
const h = hashString(key);
// Roughly distribute unknown locations across -60..+60 lat, -180..+180 lon
const lat = ((h % 12000) / 100) - 60; // -60 .. +60
const lng = ((((h / 12000) | 0) % 36000) / 100) - 180; // -180 .. +180
base = [lat, lng];
}
const count = locationCounts[key] || 0;
locationCounts[key] = count + 1;
// Slight radial offset for multiple servers in the same city
const offset = 0.15; // degrees (~1520km)
const angle = (count * 2 * Math.PI) / 6; // hex-like distribution
const lat = base[0] + offset * Math.sin(angle);
const lng = base[1] + offset * Math.cos(angle);
const popupContent = `
<div class="text-sm font-medium">${escapeHtml(s.name)}</div>
<div class="text-xs text-slate-400">${escapeHtml(s.provider)}</div>
${
s.ipv4
? `<div class="text-xs mt-1 font-mono text-slate-300">${escapeHtml(s.ipv4)}</div>`
: ""
}
${
rawLoc
? `<div class="text-[11px] text-slate-500 mt-1">${escapeHtml(rawLoc)}</div>`
: ""
}
<a href="/servers/${s.id}" class="text-indigo-400 underline text-xs mt-2 block">Details</a>
`;
const marker = L.circleMarker([lat, lng], {
radius: 6,
fillColor: "#6366f1",
color: "#1e3a8a",
weight: 1,
opacity: 0.8,
fillOpacity: 0.9
}).bindPopup(popupContent);
marker.addTo(map);
markers.push(marker);
}
// Auto-fit map to markers if we have any
if (markers.length > 0) {
const group = L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.25));
} else {
// No markers → keep default world view
map.setView([20, 0], 2);
}
});
</script>
{% endblock %}