diff --git a/.gitignore b/.gitignore index 057f8d5..5057249 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ data/ dmrmap compose.override.yml .env +static/talkgroups.json diff --git a/Dockerfile b/Dockerfile index 0370c4d..ea6f28a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,5 +13,6 @@ COPY --from=builder /build/migrations/ ./migrations/ COPY static/ ./static/ COPY rptrs.json . COPY bmrptrs.json . +RUN wget -qO static/talkgroups.json https://api.brandmeister.network/v2/talkgroup EXPOSE 8080 CMD ["./dmrmap"] diff --git a/Dockerfile.dev b/Dockerfile.dev index 90158c0..68e035e 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,5 @@ FROM golang:1.25-alpine RUN go install github.com/air-verse/air@latest +RUN wget -qO /usr/local/share/talkgroups.json https://api.brandmeister.network/v2/talkgroup WORKDIR /app -CMD ["air"] +CMD cp -n /usr/local/share/talkgroups.json static/talkgroups.json 2>/dev/null; air diff --git a/README.md b/README.md index ead3742..077df1b 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,50 @@ # DMRmap -A web app that visualizes DMR repeaters on an interactive map. Filter by band, search by address, and find repeaters along a driving route. +A web app that visualizes DMR repeaters on an interactive map. Filter by band and network, search by callsign or address, find repeaters along a driving route, and export CPS-ready channel configurations. **Live:** https://dmrmap.kida.io ## Features -- Full-screen OpenStreetMap with DMR repeater markers (blue for 2m, red for 70cm) -- Band filtering (2m / 70cm / both) -- Address-to-address routing with repeater corridor search (10 km) -- Address autocomplete via Nominatim -- Driving routes via OSRM +- Interactive OpenStreetMap with repeater markers (blue = VHF/2m, red = UHF/70cm) +- Band filtering (2m / 70cm) and network filtering (BrandMeister, DMR+, TGIF, Other) +- Toggle visibility of personal hotspots and inactive repeaters +- Free-text search by callsign, city, or DMR ID +- Address-to-address routing via OSRM with adjustable corridor width +- Click-to-pin with adjustable radius search +- Detailed repeater popups (frequencies, color code, timeslots, hardware, power, antenna height, BrandMeister status) +- Real-time Last Heard heatmap via BrandMeister WebSocket +- CPS Studio: manage talk groups, set timeslots, export Motorola CPS 2.0 compatible XML - Coordinate display with Maidenhead grid locator -- Dark mode (follows system preference, manual toggle) +- Dark mode (follows system preference) +- Internationalization (English, German, Spanish, French, Italian, Polish) - No external CDN dependencies — all assets served locally -## Requirements +## Tech Stack -- Go 1.25+ -- Docker (optional) +- **Backend:** Go (stdlib `net/http`), PostgreSQL 17 via `pgx/v5`, migrations via `goose/v3` +- **Frontend:** Vanilla JavaScript, Leaflet, i18next — no build step +- **Infrastructure:** Docker Compose, multi-stage Dockerfile -## Running locally +## External Services -```sh -go run . -``` +| Service | Usage | Auth | +|---------|-------|------| +| [RadioID](https://radioid.net/database/dumps) | Repeater database (seeded from `rptrs.json` dump) | None | +| [BrandMeister API](https://wiki.brandmeister.network/index.php/API) | Device status sync (`/v2/device/{id}`), talk group registry (`/v2/talkgroup`) | None (public read) | +| [BrandMeister WebSocket](https://wiki.brandmeister.network/index.php/API/Last_Heard) | Real-time Last Heard feed for heatmap via socket.io | None | +| [Nominatim](https://nominatim.openstreetmap.org) | Address geocoding and autocomplete (client-side) | None | +| [OSRM](https://project-osrm.org) | Driving route calculation (client-side) | None | -Open http://localhost:8080. The SQLite database is seeded automatically from `rptrs.json` on first startup. +The BrandMeister talk group registry is fetched once at Docker build time and bundled as `static/talkgroups.json`. It is used for talk group name resolution and autocomplete in CPS Studio. -## Docker +## Running with Docker ```sh docker compose up --build ``` -The database is persisted in a named volume across restarts. +Open http://localhost:8080. PostgreSQL data is persisted in a named volume. ## Development @@ -44,9 +54,54 @@ Hot-reload with [air](https://github.com/air-verse/air): docker compose -f compose.dev.yml up --build ``` +The dev container mounts the project directory, so changes to Go and frontend files are picked up automatically. + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | `postgres://dmrmap:dmrmap@localhost:5432/dmrmap?sslmode=disable` | PostgreSQL connection string | +| `JSON_PATH` | `rptrs.json` | Path to RadioID repeater dump | +| `BMRPTRS_PATH` | `bmrptrs.json` | Path to BrandMeister repeater dump | +| `LISTEN_ADDR` | `:8080` | HTTP listen address | +| `BM_SYNC` | *(disabled)* | Set to `true` to enable periodic BrandMeister device sync | +| `ADMIN_TOKEN` | *(disabled)* | Set to enable the admin interface at `/admin/` | + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/repeaters` | Repeaters within map bounding box (query: `minLat`, `maxLat`, `minLng`, `maxLng`, `band`, `network`, `hotspots`, `inactive`) | +| GET | `/api/repeaters/radius` | Repeaters within radius of a point (query: `lat`, `lng`, `radius`) | +| POST | `/api/repeaters/route` | Repeaters along a route corridor (body: GeoJSON coordinates + `corridor` width) | +| GET | `/api/repeater` | Single repeater by ID (query: `id`) | +| GET | `/api/repeaters/search` | Free-text search (query: `q`) | + +## Project Structure + +``` +├── main.go # Route registration, env config +├── db.go # DB queries, Repeater struct, geo helpers +├── handlers.go # Public API handlers +├── admin.go # Admin auth middleware + admin API +├── bmdevice.go # BrandMeister device sync +├── migrations/ # SQL migrations (goose) +├── static/ +│ ├── app.js # Frontend application logic +│ ├── style.css # Styles with dark mode support +│ ├── index.html # Single-page HTML shell +│ ├── i18n.js # i18next initialization +│ ├── locales/ # Translation files (en, de, es, fr, it, pl) +│ └── lib/ # Vendored JS libraries (Leaflet, i18next, socket.io) +├── Dockerfile # Production multi-stage build +├── Dockerfile.dev # Development image with air +├── compose.yml # Production Docker Compose +└── compose.dev.yml # Development Docker Compose +``` + ## Data -Repeater data provided by [RadioID](https://radioid.net/database/dumps). This project is not affiliated with or endorsed by RadioID. +Repeater data provided by [RadioID](https://radioid.net/database/dumps). Availability and talk group data by [BrandMeister](https://brandmeister.network). This project is not affiliated with or endorsed by RadioID or BrandMeister. ## License diff --git a/static/app.js b/static/app.js index 76d0ea1..0efa0de 100644 --- a/static/app.js +++ b/static/app.js @@ -60,6 +60,23 @@ clearBtn.style.display = "none"; + // === CPS Studio DOM + State === + var cpsModal = document.getElementById("cps-modal"); + var cpsModalClose = document.getElementById("cps-modal-close"); + var cpsTgTbody = document.getElementById("cps-tg-tbody"); + var cpsRepeaterTags = document.getElementById("cps-repeater-tags"); + var cpsAddTgInput = document.getElementById("cps-add-tg-input"); + var cpsAcList = document.getElementById("cps-ac-list"); + var cpsDownloadBtn = document.getElementById("cps-download-btn"); + var cpsCopyBtn = document.getElementById("cps-copy-btn"); + var cpsChannelCount = document.getElementById("cps-channel-count"); + + var cpsRepeaters = []; + var cpsActiveRepeaters = {}; + var cpsTalkgroups = []; + var tgRegistry = null; + var tgRegistryPromise = null; + // === Autocomplete === function setupAutocomplete(input, listEl) { var acTimer = null; @@ -233,6 +250,272 @@ .replace(/"/g, """); } + // Fallback TG names from wiki.bm262.de for German-speaking region + var BM262_TALKGROUPS = { + "262": "Deutschland", "263": "MultiMode DL", + "2620": "Sachsen-Anhalt/Mecklenburg-Vorpommern", "2621": "Berlin/Brandenburg", + "2622": "Hamburg/Schleswig-Holstein", "2623": "Niedersachsen/Bremen", + "2624": "Nordrhein-Westfalen", "2625": "Rheinland-Pfalz/Saarland", + "2626": "Hessen", "2627": "Baden-W\u00fcrttemberg", "2628": "Bayern", + "2629": "Sachsen/Th\u00fcringen", "26200": "TAC 1", "26209": "Brandenburg", + "26212": "Berlin-City", "26220": "Grossraum Hamburg", "26221": "Hamburg-City", + "26222": "Ostholstein-Nord", "26223": "Chaoswelle", "26224": "Elbe-Weser", + "26225": "AFU-Nord", "26226": "DMR Netzverbund Nord", "26228": "Ostholstein S\u00fcd", + "26231": "NI Mitte", "26232": "Dreil\u00e4ndereck Mitte Deutschland", "26233": "TAC 3", + "26234": "NI-Sued", "26236": "NI-Nord", "26239": "NI Ost", + "26241": "Rheinland", "26242": "Muensterland", "26243": "Ruhrgebiet", + "26245": "Rheinland-Sued", "26249": "Siebengebirge", "26250": "Saarland", + "26256": "Eifel-Hunsrueck", "26257": "Siegerland", "26260": "Mittelhessen", + "26261": "Nordhessen", "26262": "Rhein-Main-Neckar", "26263": "Bergstrasse", + "26266": "TAC 4", "26270": "Stuttgart", "26271": "Baden", + "26272": "Neckar-Odenwald", "26273": "BW-Ostalb", "26274": "BW B\u00f6blingen", + "26275": "Schwarzwald Nord", "26276": "Neckar-Alb", "26277": "Schwarzwald", + "26278": "BW Herrenberg", "26279": "BW Mittlerer Neckar", "26280": "Niederbayern", + "26282": "Schwaben", "26283": "Region M\u00fcnchen", "26284": "Region Franken", + "26285": "Region Ingolstadt", "26286": "Coburg-Rennsteig", + "26287": "Allg\u00e4u-Bodensee", "26288": "Region Bayern Oberland", + "26289": "Oberpfalz", "26298": "Th\u00fcringen", "26299": "TAC 2", + "26300": "Multimode TAC 1", "26301": "Sachsen-Erzgebirge", + "26322": "D22 - Neue Medien", "26331": "NI Ost", "26333": "Multimode TAC 3", + "26338": "afu38", "26345": "Paderborn", "26346": "Ostwestfalen-Lippe", + "26347": "IGA Rhein-Erft", "26348": "Westmuensterland", "26349": "Hochsauerland", + "26366": "Multimode TAC 4", "26375": "Bodensee-Oberschwaben", "26377": "Ortenau", + "26384": "Schrobenhausen", "26399": "Multimode TAC2", "26426": "FM-Funknetz", + "26429": "DL-Nordwest", "262810": "Projekt Pegasus", "263112": "HiOrg-Talk EmComm", + "263113": "(Un)Wetter Netz", "263333": "Twitterrunde", + "263852": "DARC Dachau - C06 Runde", "264022": "Whitesticker" + }; + + function tgName(id) { + var key = String(id); + if (tgRegistry && tgRegistry[key]) return tgRegistry[key]; + if (BM262_TALKGROUPS[key]) return BM262_TALKGROUPS[key]; + return ""; + } + + function escapeXml(str) { + if (!str) return ""; + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function buildChannel(alias, slot, colorCode, txHz, rxHz) { + var slotName = slot === "SLOT1" ? "1" : "2"; + return ' \n' + + ' DGTLCONV6PT25\n' + + ' ' + escapeXml(alias) + '\n' + + ' ' + slot + '\n' + + ' ' + colorCode + '\n' + + ' ' + txHz + '\n' + + ' ' + rxHz + '\n' + + ' MTCHCLRCD\n' + + ' 180\n' + + ' \n'; + } + + function generateCpsXml(repeaters, talkgroups) { + var channels = ""; + repeaters.forEach(function (r) { + var txHz = Math.round(r.freq_tx * 1000000); + var rxHz = Math.round(r.freq_rx * 1000000); + var cc = r.color_code; + talkgroups.forEach(function (tg) { + var slot = tg.slot === "1" ? "SLOT1" : "SLOT2"; + var alias = (r.callsign + " TG" + tg.id).substring(0, 16); + channels += buildChannel(alias, slot, cc, txHz, rxHz); + }); + }); + return '\n' + + "\n" + + ' \n' + + ' \n' + + ' DMRmap\n' + + ' NORMAL\n' + + ' \n' + + channels + + " \n" + + " \n" + + " \n" + + ""; + } + + // === BrandMeister API === + function fetchTgRegistry() { + if (tgRegistry) return Promise.resolve(tgRegistry); + if (tgRegistryPromise) return tgRegistryPromise; + tgRegistryPromise = fetch("/talkgroups.json") + .then(function (r) { return r.json(); }) + .then(function (data) { tgRegistry = data; return data; }) + .catch(function () { tgRegistryPromise = null; return {}; }); + return tgRegistryPromise; + } + + // === CPS Studio Modal === + function openCpsModal(repeaters) { + cpsRepeaters = repeaters; + cpsActiveRepeaters = {}; + repeaters.forEach(function (r) { cpsActiveRepeaters[r.id] = true; }); + cpsTalkgroups = []; + cpsModal.style.display = "flex"; + document.body.style.overflow = "hidden"; + cpsTgTbody.innerHTML = ""; + renderRepeaterTags(); + renderTgTable(); + updateChannelCount(); + updateCpsButtons(); + translateDOM(); + } + + function renderRepeaterTags() { + cpsRepeaterTags.innerHTML = ""; + cpsRepeaters.forEach(function (r) { + var tag = document.createElement("button"); + tag.type = "button"; + tag.className = "cps-rptr-tag" + (cpsActiveRepeaters[r.id] ? " active" : ""); + tag.textContent = r.callsign; + tag.addEventListener("click", function () { + if (cpsActiveRepeaters[r.id]) { + delete cpsActiveRepeaters[r.id]; + } else { + cpsActiveRepeaters[r.id] = true; + } + tag.classList.toggle("active"); + updateChannelCount(); + updateCpsButtons(); + }); + cpsRepeaterTags.appendChild(tag); + }); + } + + function getActiveRepeaters() { + return cpsRepeaters.filter(function (r) { return cpsActiveRepeaters[r.id]; }); + } + + function updateCpsButtons() { + var hasData = getActiveRepeaters().length > 0 && cpsTalkgroups.length > 0; + cpsDownloadBtn.disabled = !hasData; + cpsCopyBtn.disabled = !hasData; + } + + function closeCpsModal() { + cpsModal.style.display = "none"; + document.body.style.overflow = ""; + cpsAddTgInput.value = ""; + cpsAcList.innerHTML = ""; + } + + function renderTgTable() { + cpsTgTbody.innerHTML = ""; + cpsTalkgroups.forEach(function (tg, idx) { + var tr = document.createElement("tr"); + + var tdId = document.createElement("td"); + tdId.className = "tg-id"; + tdId.textContent = tg.id; + tr.appendChild(tdId); + + var tdName = document.createElement("td"); + tdName.className = "tg-name"; + tdName.textContent = tg.name || t("cps_unknown_tg"); + tr.appendChild(tdName); + + var tdSlot = document.createElement("td"); + var toggle = document.createElement("div"); + toggle.className = "ts-toggle"; + var btn1 = document.createElement("button"); + btn1.type = "button"; + btn1.textContent = "TS1"; + if (tg.slot === "1") btn1.className = "active"; + var btn2 = document.createElement("button"); + btn2.type = "button"; + btn2.textContent = "TS2"; + if (tg.slot === "2") btn2.className = "active"; + btn1.addEventListener("click", (function (i) { + return function () { cpsTalkgroups[i].slot = "1"; renderTgTable(); updateChannelCount(); }; + })(idx)); + btn2.addEventListener("click", (function (i) { + return function () { cpsTalkgroups[i].slot = "2"; renderTgTable(); updateChannelCount(); }; + })(idx)); + toggle.appendChild(btn1); + toggle.appendChild(btn2); + tdSlot.appendChild(toggle); + tr.appendChild(tdSlot); + + var tdRemove = document.createElement("td"); + var removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "tg-remove-btn"; + removeBtn.innerHTML = "×"; + removeBtn.addEventListener("click", (function (i) { + return function () { cpsTalkgroups.splice(i, 1); renderTgTable(); updateChannelCount(); updateCpsButtons(); }; + })(idx)); + tdRemove.appendChild(removeBtn); + tr.appendChild(tdRemove); + + cpsTgTbody.appendChild(tr); + }); + } + + function updateChannelCount() { + var active = getActiveRepeaters().length; + var total = active * cpsTalkgroups.length; + cpsChannelCount.textContent = t("cps_channel_count", { + repeaters: active, + tgs: cpsTalkgroups.length, + channels: total + }); + } + + function addTalkgroupById(tgId) { + if (cpsTalkgroups.some(function (tg) { return tg.id === tgId; })) return; + cpsTalkgroups.push({ id: tgId, name: tgName(tgId), slot: "2" }); + cpsTalkgroups.sort(function (a, b) { return a.id - b.id; }); + renderTgTable(); + updateChannelCount(); + updateCpsButtons(); + } + + function downloadCpsXml() { + var active = getActiveRepeaters(); + if (!cpsTalkgroups.length || !active.length) return; + var xml = generateCpsXml(active, cpsTalkgroups); + var blob = new Blob([xml], { type: "application/xml" }); + var url = URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = "dmrmap-cps.xml"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + function copyCpsXml() { + var active = getActiveRepeaters(); + if (!cpsTalkgroups.length || !active.length) return; + var xml = generateCpsXml(active, cpsTalkgroups); + var origText = cpsCopyBtn.textContent; + navigator.clipboard.writeText(xml).then(function () { + cpsCopyBtn.textContent = t("cps_copied"); + setTimeout(function () { cpsCopyBtn.textContent = origText; }, 2000); + }).catch(function () { + var ta = document.createElement("textarea"); + ta.value = xml; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + cpsCopyBtn.textContent = t("cps_copied"); + setTimeout(function () { cpsCopyBtn.textContent = origText; }, 2000); + }); + } + function showCount(count) { countEl.textContent = t("repeater_count", { count: count }); } @@ -625,6 +908,16 @@ function renderRepeaterList(container, repeaters) { container.innerHTML = ""; + if (repeaters.length > 0) { + var cpsBtn = document.createElement("button"); + cpsBtn.className = "cps-copy-btn"; + cpsBtn.textContent = t("cps_studio_btn"); + cpsBtn.addEventListener("click", function (e) { + e.stopPropagation(); + openCpsModal(repeaters); + }); + container.appendChild(cpsBtn); + } repeaters.forEach(function (r) { var item = document.createElement("div"); item.className = "pin-list-item"; @@ -971,6 +1264,105 @@ fetchRepeaters(); }); + // === CPS Studio Events === + cpsModalClose.addEventListener("click", closeCpsModal); + document.querySelector(".cps-modal-backdrop").addEventListener("click", closeCpsModal); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && cpsModal.style.display !== "none") { + if (cpsAcList.children.length) { + cpsAcList.innerHTML = ""; + cpsAcIdx = -1; + } else { + closeCpsModal(); + } + } + }); + cpsDownloadBtn.addEventListener("click", downloadCpsXml); + cpsCopyBtn.addEventListener("click", copyCpsXml); + + // TG autocomplete + var cpsAcIdx = -1; + var cpsAcTimer = null; + + function searchTalkgroups(query) { + if (!tgRegistry) return []; + var q = query.toLowerCase(); + var isNum = /^\d+$/.test(query); + var results = []; + for (var id in tgRegistry) { + if (isNum ? id.indexOf(query) === 0 : tgRegistry[id].toLowerCase().indexOf(q) !== -1) { + results.push({ id: parseInt(id), name: tgRegistry[id] }); + } + if (results.length >= 8) break; + } + // also search BM262 fallback + for (var bmId in BM262_TALKGROUPS) { + if (results.some(function (r) { return r.id === parseInt(bmId); })) continue; + if (isNum ? bmId.indexOf(query) === 0 : BM262_TALKGROUPS[bmId].toLowerCase().indexOf(q) !== -1) { + results.push({ id: parseInt(bmId), name: BM262_TALKGROUPS[bmId] }); + } + if (results.length >= 8) break; + } + return results; + } + + function renderCpsAc(results) { + cpsAcList.innerHTML = ""; + cpsAcIdx = -1; + results.forEach(function (r) { + var li = document.createElement("li"); + li.textContent = r.id + " — " + (r.name || t("cps_unknown_tg")); + if (cpsTalkgroups.some(function (tg) { return tg.id === r.id; })) { + li.className = "ac-disabled"; + } else { + li.addEventListener("click", function () { + addTalkgroupById(r.id); + cpsAddTgInput.value = ""; + cpsAcList.innerHTML = ""; + cpsAcIdx = -1; + cpsAddTgInput.focus(); + }); + } + cpsAcList.appendChild(li); + }); + } + + cpsAddTgInput.addEventListener("input", function () { + clearTimeout(cpsAcTimer); + var val = cpsAddTgInput.value.trim(); + if (!val) { cpsAcList.innerHTML = ""; cpsAcIdx = -1; return; } + cpsAcTimer = setTimeout(function () { + renderCpsAc(searchTalkgroups(val)); + }, 120); + }); + + cpsAddTgInput.addEventListener("keydown", function (e) { + var items = cpsAcList.querySelectorAll("li:not(.ac-disabled)"); + if (e.key === "ArrowDown") { + e.preventDefault(); + cpsAcIdx = Math.min(cpsAcIdx + 1, items.length - 1); + items.forEach(function (li, i) { li.classList.toggle("active", i === cpsAcIdx); }); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + cpsAcIdx = Math.max(cpsAcIdx - 1, 0); + items.forEach(function (li, i) { li.classList.toggle("active", i === cpsAcIdx); }); + } else if (e.key === "Enter") { + e.preventDefault(); + if (cpsAcIdx >= 0 && items[cpsAcIdx]) { + items[cpsAcIdx].click(); + } else { + var val = cpsAddTgInput.value.trim(); + var tgId = parseInt(val); + if (!isNaN(tgId) && tgId > 0) { + addTalkgroupById(tgId); + cpsAddTgInput.value = ""; + cpsAcList.innerHTML = ""; + cpsAcIdx = -1; + } + } + } + }); + // === Hash navigation === function checkHash() { var hash = decodeURIComponent(window.location.hash.replace("#", "")); @@ -1007,6 +1399,8 @@ window.addEventListener("hashchange", checkHash); // === Init === + fetchTgRegistry(); + function initApp() { if (window.location.hash) { checkHash(); diff --git a/static/index.html b/static/index.html index 00d19e1..582da7f 100644 --- a/static/index.html +++ b/static/index.html @@ -76,6 +76,41 @@ Impressum · Datenschutz · Source
+ diff --git a/static/locales/de.json b/static/locales/de.json index f51c8b9..f2e0fc6 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -38,5 +38,17 @@ "back_to_map": "Zur Karte", "impressum": "Impressum", "datenschutz": "Datenschutz", - "source": "Quellcode" + "source": "Quellcode", + "cps_studio_btn": "CPS Studio", + "cps_studio_title": "CPS Studio", + "cps_experimental_note": "Diese Funktion befindet sich noch in der Entwicklung. Bitte überprüfe die generierte Ausgabe vor dem Import.", + "cps_tg_id": "TG", + "cps_tg_name": "Name", + "cps_tg_slot": "Zeitschlitz", + "cps_add_tg_placeholder": "TG per ID oder Name hinzufügen...", + "cps_download_xml": "XML herunterladen", + "cps_copy_xml": "In Zwischenablage", + "cps_copied": "Kopiert!", + "cps_unknown_tg": "Unbekannt", + "cps_channel_count": "{{channels}} Kanäle ({{repeaters}} Relais × {{tgs}} Talkgroups)" } diff --git a/static/locales/en.json b/static/locales/en.json index aafc65b..35a724e 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -38,5 +38,17 @@ "back_to_map": "Back to map", "impressum": "Legal Notice", "datenschutz": "Privacy Policy", - "source": "Source" + "source": "Source", + "cps_studio_btn": "CPS Studio", + "cps_studio_title": "CPS Studio", + "cps_experimental_note": "This feature is still in development. Please review the generated output before importing.", + "cps_tg_id": "TG", + "cps_tg_name": "Name", + "cps_tg_slot": "Timeslot", + "cps_add_tg_placeholder": "Add TG by ID or name...", + "cps_download_xml": "Download XML", + "cps_copy_xml": "Copy to Clipboard", + "cps_copied": "Copied!", + "cps_unknown_tg": "Unknown", + "cps_channel_count": "{{channels}} channels ({{repeaters}} repeaters × {{tgs}} talk groups)" } diff --git a/static/locales/es.json b/static/locales/es.json index 4c95f92..b735105 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -38,5 +38,17 @@ "back_to_map": "Volver al mapa", "impressum": "Aviso legal", "datenschutz": "Política de privacidad", - "source": "Código fuente" + "source": "Código fuente", + "cps_studio_btn": "CPS Studio", + "cps_studio_title": "CPS Studio", + "cps_experimental_note": "Esta función aún está en desarrollo. Revisa el resultado generado antes de importarlo.", + "cps_tg_id": "TG", + "cps_tg_name": "Nombre", + "cps_tg_slot": "Timeslot", + "cps_add_tg_placeholder": "Añadir TG por ID o nombre...", + "cps_download_xml": "Descargar XML", + "cps_copy_xml": "Copiar al portapapeles", + "cps_copied": "¡Copiado!", + "cps_unknown_tg": "Desconocido", + "cps_channel_count": "{{channels}} canales ({{repeaters}} repetidores × {{tgs}} grupos)" } diff --git a/static/locales/fr.json b/static/locales/fr.json index b9a2be8..a24fcc7 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -38,5 +38,17 @@ "back_to_map": "Retour à la carte", "impressum": "Mentions légales", "datenschutz": "Politique de confidentialité", - "source": "Code source" + "source": "Code source", + "cps_studio_btn": "CPS Studio", + "cps_studio_title": "CPS Studio", + "cps_experimental_note": "Cette fonctionnalité est encore en développement. Veuillez vérifier le résultat généré avant de l'importer.", + "cps_tg_id": "TG", + "cps_tg_name": "Nom", + "cps_tg_slot": "Timeslot", + "cps_add_tg_placeholder": "Ajouter TG par ID ou nom...", + "cps_download_xml": "Télécharger XML", + "cps_copy_xml": "Copier dans le presse-papiers", + "cps_copied": "Copié !", + "cps_unknown_tg": "Inconnu", + "cps_channel_count": "{{channels}} canaux ({{repeaters}} relais × {{tgs}} groupes)" } diff --git a/static/locales/it.json b/static/locales/it.json index 2f11ad7..99b63ca 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -38,5 +38,17 @@ "back_to_map": "Torna alla mappa", "impressum": "Note legali", "datenschutz": "Informativa sulla privacy", - "source": "Codice sorgente" + "source": "Codice sorgente", + "cps_studio_btn": "CPS Studio", + "cps_studio_title": "CPS Studio", + "cps_experimental_note": "Questa funzione è ancora in fase di sviluppo. Verifica l'output generato prima dell'importazione.", + "cps_tg_id": "TG", + "cps_tg_name": "Nome", + "cps_tg_slot": "Timeslot", + "cps_add_tg_placeholder": "Aggiungi TG per ID o nome...", + "cps_download_xml": "Scarica XML", + "cps_copy_xml": "Copia negli appunti", + "cps_copied": "Copiato!", + "cps_unknown_tg": "Sconosciuto", + "cps_channel_count": "{{channels}} canali ({{repeaters}} ripetitori × {{tgs}} gruppi)" } diff --git a/static/locales/pl.json b/static/locales/pl.json index 1daec47..c569497 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -40,5 +40,17 @@ "back_to_map": "Powrót do mapy", "impressum": "Nota prawna", "datenschutz": "Polityka prywatności", - "source": "Kod źródłowy" + "source": "Kod źródłowy", + "cps_studio_btn": "CPS Studio", + "cps_studio_title": "CPS Studio", + "cps_experimental_note": "Ta funkcja jest wciąż w fazie rozwoju. Sprawdź wygenerowany wynik przed importem.", + "cps_tg_id": "TG", + "cps_tg_name": "Nazwa", + "cps_tg_slot": "Timeslot", + "cps_add_tg_placeholder": "Dodaj TG po ID lub nazwie...", + "cps_download_xml": "Pobierz XML", + "cps_copy_xml": "Kopiuj do schowka", + "cps_copied": "Skopiowano!", + "cps_unknown_tg": "Nieznany", + "cps_channel_count": "{{channels}} kanałów ({{repeaters}} przemienników × {{tgs}} grup)" } diff --git a/static/style.css b/static/style.css index 7b7760f..42ee629 100644 --- a/static/style.css +++ b/static/style.css @@ -325,6 +325,7 @@ html, body { cursor: pointer; border-bottom: 1px solid var(--border-lighter); line-height: 1.3; + color: var(--text); } .autocomplete-list li:last-child { @@ -560,6 +561,25 @@ html, body { white-space: nowrap; } +.cps-copy-btn { + display: block; + width: 100%; + padding: 5px 8px; + margin-bottom: 4px; + background: var(--clear-btn-bg); + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 11px; + cursor: pointer; + text-align: center; +} + +.cps-copy-btn:hover { + background: var(--clear-btn-hover); + color: var(--text); +} + .attribution { margin-top: 8px; padding-top: 8px; @@ -636,6 +656,310 @@ html, body { animation: heatmap-pulse 2s ease-in-out infinite; } +/* CPS Studio Modal */ + +.cps-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; +} + +.cps-modal-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); +} + +.cps-modal-dialog { + position: relative; + background: var(--bg); + border-radius: 10px; + box-shadow: 0 8px 32px var(--shadow); + width: 90%; + max-width: 600px; + max-height: 85vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.cps-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border-light); +} + +.cps-modal-header h2 { + font-size: 16px; + font-weight: 700; + color: var(--text); + margin: 0; +} + +.cps-exp-badge { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + background: #ff9800; + color: #fff; + padding: 2px 6px; + border-radius: 3px; + margin-right: auto; + margin-left: 8px; +} + +.cps-exp-note { + font-size: 11px; + color: var(--text-faint); + margin: 0 0 10px 0; + line-height: 1.4; +} + +.cps-modal-close { + background: none; + border: none; + font-size: 22px; + color: var(--text-faint); + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + line-height: 1; +} + +.cps-modal-close:hover { + color: var(--text); + background: var(--hover-bg); +} + +.cps-modal-body { + flex: 1; + overflow-y: auto; + padding: 14px 18px; +} + +.cps-repeater-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; +} + +.cps-rptr-tag { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--text); + transition: opacity 0.15s, background 0.15s; + user-select: none; +} + +.cps-rptr-tag.active { + background: #4CAF50; + border-color: #4CAF50; + color: white; +} + +.cps-rptr-tag:not(.active) { + opacity: 0.45; +} + +.cps-rptr-tag:hover { + opacity: 1; +} + +.cps-tg-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.cps-tg-table th { + text-align: left; + font-weight: 600; + color: var(--text-muted); + padding: 6px 4px; + border-bottom: 2px solid var(--border); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.cps-tg-table td { + padding: 6px 4px; + border-bottom: 1px solid var(--border-lighter); + color: var(--text); + vertical-align: middle; +} + +.cps-tg-table .tg-id { + font-weight: 600; + font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace; + min-width: 60px; +} + +.cps-tg-table .tg-name { + color: var(--text-muted); +} + +.ts-toggle { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; +} + +.ts-toggle button { + padding: 3px 10px; + border: none; + background: none; + font-size: 11px; + font-weight: 600; + cursor: pointer; + color: var(--text-faint); +} + +.ts-toggle button.active { + background: #4CAF50; + color: white; +} + +.ts-toggle button:not(.active):hover { + background: var(--hover-bg); +} + +.tg-remove-btn { + background: none; + border: none; + color: var(--text-fainter); + cursor: pointer; + font-size: 16px; + padding: 2px 6px; + border-radius: 3px; + line-height: 1; +} + +.tg-remove-btn:hover { + color: #ef5350; + background: var(--hover-bg); +} + +.cps-add-tg-row { + margin-top: 8px; +} + +.cps-add-tg-row input { + width: 100%; + box-sizing: border-box; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 5px; + font-size: 12px; + background: var(--input-bg); + color: var(--text); + outline: none; +} + +.cps-add-tg-row input:focus { + border-color: #4CAF50; +} + +.cps-ac-list:not(:empty) { + display: block; +} + +.autocomplete-list li.ac-disabled { + opacity: 0.4; + cursor: default; +} + +.autocomplete-list li.ac-disabled:hover { + background: none; +} + +.cps-channel-count { + margin-top: 8px; + font-size: 11px; + color: var(--text-fainter); +} + +.cps-modal-footer { + display: flex; + gap: 8px; + padding: 12px 18px; + border-top: 1px solid var(--border-light); +} + +.cps-btn-primary { + flex: 1; + padding: 8px 16px; + background: #4CAF50; + color: white; + border: none; + border-radius: 5px; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.cps-btn-primary:hover { + background: #43A047; +} + +.cps-btn-primary:disabled { + background: #A5D6A7; + cursor: default; +} + +.cps-btn-secondary { + flex: 1; + padding: 8px 16px; + background: var(--clear-btn-bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 5px; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.cps-btn-secondary:hover { + background: var(--clear-btn-hover); +} + +.cps-btn-secondary:disabled { + opacity: 0.5; + cursor: default; +} + +@media (prefers-color-scheme: dark) { + .cps-btn-primary:disabled { + background: #2e7d32; + color: #a5d6a7; + } + + .cps-modal-backdrop { + background: rgba(0, 0, 0, 0.7); + } +} + @media (max-width: 768px) { #controls { top: 10px; @@ -644,4 +968,12 @@ html, body { width: auto; padding: 8px 10px; } + + .cps-modal-dialog { + width: 100%; + max-width: none; + max-height: 100vh; + border-radius: 0; + height: 100%; + } }