Implement user reports

This commit is contained in:
Marcus Kida 2026-08-04 14:33:00 +02:00
parent 8f842a2df3
commit 14550b3797
21 changed files with 1191 additions and 10 deletions

View file

@ -14,6 +14,7 @@ A web app that visualizes DMR repeaters on an interactive map. Filter by band an
- Click-to-pin with adjustable radius search - Click-to-pin with adjustable radius search
- Detailed repeater popups (frequencies, color code, timeslots, hardware, power, antenna height, BrandMeister status) - Detailed repeater popups (frequencies, color code, timeslots, hardware, power, antenna height, BrandMeister status)
- Real-time Last Heard heatmap via BrandMeister WebSocket - Real-time Last Heard heatmap via BrandMeister WebSocket
- User reports for incorrect repeater data (offline, wrong frequency, changed callsign, other), reviewable in the admin area with optional ntfy push notifications
- CPS Studio: manage talk groups, set timeslots, export Motorola CPS 2.0 compatible XML - CPS Studio: manage talk groups, set timeslots, export Motorola CPS 2.0 compatible XML
- Coordinate display with Maidenhead grid locator - Coordinate display with Maidenhead grid locator
- Dark mode (follows system preference) - Dark mode (follows system preference)
@ -24,7 +25,7 @@ A web app that visualizes DMR repeaters on an interactive map. Filter by band an
- **Backend:** Go (stdlib `net/http`), PostgreSQL 17 via `pgx/v5`, migrations via `goose/v3` - **Backend:** Go (stdlib `net/http`), PostgreSQL 17 via `pgx/v5`, migrations via `goose/v3`
- **Frontend:** TypeScript (bundled with esbuild), Leaflet, i18next - **Frontend:** TypeScript (bundled with esbuild), Leaflet, i18next
- **Testing:** Vitest (92 unit tests) - **Testing:** Vitest (115 unit tests)
- **Infrastructure:** Docker Compose, multi-stage Dockerfile - **Infrastructure:** Docker Compose, multi-stage Dockerfile
## External Services ## External Services
@ -36,6 +37,7 @@ A web app that visualizes DMR repeaters on an interactive map. Filter by band an
| [BrandMeister WebSocket](https://wiki.brandmeister.network/index.php/API/Last_Heard) | Real-time Last Heard feed for heatmap via socket.io | None | | [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 | | [Nominatim](https://nominatim.openstreetmap.org) | Address geocoding and autocomplete (client-side) | None |
| [OSRM](https://project-osrm.org) | Driving route calculation (client-side) | None | | [OSRM](https://project-osrm.org) | Driving route calculation (client-side) | None |
| [ntfy](https://ntfy.sh) | Push notification when a user report is submitted (optional) | Access token (bearer or basic auth) |
The BrandMeister talk group registry is fetched once at Docker build time and bundled as `frontend/static/talkgroups.json`. It is used for talk group name resolution and autocomplete in CPS Studio. The BrandMeister talk group registry is fetched once at Docker build time and bundled as `frontend/static/talkgroups.json`. It is used for talk group name resolution and autocomplete in CPS Studio.
@ -75,6 +77,13 @@ docker compose -f compose.test.yml run --rm test
| `MIGRATIONS_DIR` | `migrations` | Path to SQL migration files | | `MIGRATIONS_DIR` | `migrations` | Path to SQL migration files |
| `BM_SYNC` | *(disabled)* | Set to `true` to enable periodic BrandMeister device sync | | `BM_SYNC` | *(disabled)* | Set to `true` to enable periodic BrandMeister device sync |
| `ADMIN_TOKEN` | *(disabled)* | Set to enable the admin interface at `/admin/` | | `ADMIN_TOKEN` | *(disabled)* | Set to enable the admin interface at `/admin/` |
| `NTFY_URL` | *(disabled)* | ntfy server base URL (e.g. `https://ntfy.example.com`). Together with `NTFY_TOPIC` it enables push notifications for new user reports |
| `NTFY_TOPIC` | *(disabled)* | ntfy topic that report notifications are published to |
| `NTFY_USER` | *(none)* | ntfy username. When set, the token is sent as HTTP basic auth (`user:token`) |
| `NTFY_TOKEN` | *(none)* | ntfy access token. Sent as `Authorization: Bearer` when no username is set |
| `PUBLIC_BASE_URL` | *(none)* | Public base URL of the site (e.g. `https://dmrmap.de`). Used to add a click-through link to the admin view in ntfy notifications |
Both `NTFY_URL` and `NTFY_TOPIC` must be set for notifications; otherwise reports are stored but nothing is pushed.
## API Endpoints ## API Endpoints
@ -85,6 +94,7 @@ docker compose -f compose.test.yml run --rm test
| POST | `/api/repeaters/route` | Repeaters along a route corridor (body: GeoJSON coordinates + `corridor` width) | | 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/repeater` | Single repeater by ID (query: `id`) |
| GET | `/api/repeaters/search` | Free-text search (query: `q`) | | GET | `/api/repeaters/search` | Free-text search (query: `q`) |
| POST | `/api/reports` | Submit a user report about a repeater (body: `repeater_id`, `report_type``offline`, `frequency`, `callsign` or `other` — and `message`, max 300 characters). Rate limited to 5 reports per IP per hour |
## Project Structure ## Project Structure
@ -94,6 +104,8 @@ docker compose -f compose.test.yml run --rm test
│ ├── db.go # DB queries, Repeater struct, geo helpers │ ├── db.go # DB queries, Repeater struct, geo helpers
│ ├── handlers.go # Public API handlers │ ├── handlers.go # Public API handlers
│ ├── admin.go # Admin auth middleware + admin API │ ├── admin.go # Admin auth middleware + admin API
│ ├── reports.go # User report API (public + admin) and rate limiting
│ ├── ntfy.go # ntfy push notifications for new reports
│ ├── bmdevice.go # BrandMeister device sync │ ├── bmdevice.go # BrandMeister device sync
│ ├── seed.go # Database seeding from JSON dumps │ ├── seed.go # Database seeding from JSON dumps
│ ├── migrate.go # Goose migration runner │ ├── migrate.go # Goose migration runner

View file

@ -36,12 +36,15 @@ func main() {
log.Println("BM device sync disabled (set BM_SYNC=true to enable)") log.Println("BM device sync disabled (set BM_SYNC=true to enable)")
} }
notifier := newNtfyNotifierFromEnv()
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/repeaters", handleRepeaters(db)) mux.HandleFunc("/api/repeaters", handleRepeaters(db))
mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(db)) mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(db))
mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db)) mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db))
mux.HandleFunc("/api/repeater", handleRepeaterByID(db)) mux.HandleFunc("/api/repeater", handleRepeaterByID(db))
mux.HandleFunc("/api/repeaters/search", handleSearchRepeaters(db)) mux.HandleFunc("/api/repeaters/search", handleSearchRepeaters(db))
mux.HandleFunc("/api/reports", handleCreateReport(db, notifier))
mux.HandleFunc("/api/version", handleVersion()) mux.HandleFunc("/api/version", handleVersion())
if adminToken := os.Getenv("ADMIN_TOKEN"); adminToken != "" { if adminToken := os.Getenv("ADMIN_TOKEN"); adminToken != "" {
@ -53,6 +56,9 @@ func main() {
adminAPI.HandleFunc("/admin/api/repeaters/save-bm", handleAdminSaveBMData(db)) adminAPI.HandleFunc("/admin/api/repeaters/save-bm", handleAdminSaveBMData(db))
adminAPI.HandleFunc("/admin/api/repeaters/remove-bm-tag", handleAdminRemoveBMTag(db)) adminAPI.HandleFunc("/admin/api/repeaters/remove-bm-tag", handleAdminRemoveBMTag(db))
adminAPI.HandleFunc("/admin/api/repeaters/changelog", handleAdminChangelog(db)) adminAPI.HandleFunc("/admin/api/repeaters/changelog", handleAdminChangelog(db))
adminAPI.HandleFunc("/admin/api/reports", handleAdminReports(db))
adminAPI.HandleFunc("/admin/api/reports/status", handleAdminUpdateReport(db))
adminAPI.HandleFunc("/admin/api/reports/delete", handleAdminDeleteReport(db))
mux.Handle("/admin/api/", adminAuth(adminToken, adminAPI)) mux.Handle("/admin/api/", adminAuth(adminToken, adminAPI))
log.Println("Admin interface enabled at /admin/") log.Println("Admin interface enabled at /admin/")
} }

View file

@ -0,0 +1,18 @@
-- +goose Up
CREATE TABLE user_reports (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
repeater_id INTEGER NOT NULL REFERENCES repeaters(id),
callsign TEXT NOT NULL DEFAULT '',
report_type TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
resolved_at TIMESTAMPTZ
);
CREATE INDEX idx_user_reports_repeater_id ON user_reports (repeater_id);
CREATE INDEX idx_user_reports_created_at ON user_reports (created_at);
CREATE INDEX idx_user_reports_status ON user_reports (status);
-- +goose Down
DROP TABLE IF EXISTS user_reports;

101
backend/ntfy.go Normal file
View file

@ -0,0 +1,101 @@
package main
import (
"encoding/base64"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
)
// ntfyNotifier pushes user reports to an ntfy server. It is nil when the
// required environment variables are not set, in which case notify is a no-op.
type ntfyNotifier struct {
url string // full topic URL, e.g. https://ntfy.example.com/dmrmap
user string
token string
clickBase string
httpClient *http.Client
}
// newNtfyNotifierFromEnv builds a notifier from NTFY_URL / NTFY_TOPIC /
// NTFY_USER / NTFY_TOKEN. Returns nil when notifications are not configured.
func newNtfyNotifierFromEnv() *ntfyNotifier {
base := strings.TrimSpace(os.Getenv("NTFY_URL"))
topic := strings.TrimSpace(os.Getenv("NTFY_TOPIC"))
if base == "" || topic == "" {
log.Println("ntfy notifications disabled (set NTFY_URL and NTFY_TOPIC to enable)")
return nil
}
return &ntfyNotifier{
url: strings.TrimRight(base, "/") + "/" + strings.TrimLeft(topic, "/"),
user: strings.TrimSpace(os.Getenv("NTFY_USER")),
token: strings.TrimSpace(os.Getenv("NTFY_TOKEN")),
clickBase: strings.TrimRight(strings.TrimSpace(os.Getenv("PUBLIC_BASE_URL")), "/"),
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// notifyReport sends a push notification for a new user report. It never
// blocks the caller and never fails the request that triggered it.
func (n *ntfyNotifier) notifyReport(rep UserReport) {
if n == nil {
return
}
go func() {
if err := n.send(rep); err != nil {
log.Printf("ntfy: failed to publish report %d: %v", rep.ID, err)
}
}()
}
func (n *ntfyNotifier) send(rep UserReport) error {
body := reportTypeLabel(rep.ReportType)
if rep.Message != "" {
body += "\n\n" + rep.Message
}
body += fmt.Sprintf("\n\nRepeater ID: %d", rep.RepeaterID)
req, err := http.NewRequest(http.MethodPost, n.url, strings.NewReader(body))
if err != nil {
return err
}
title := fmt.Sprintf("Report: %s", rep.Callsign)
if rep.Callsign == "" {
title = fmt.Sprintf("Report: #%d", rep.RepeaterID)
}
req.Header.Set("Title", title)
req.Header.Set("Tags", "warning,radio")
req.Header.Set("Priority", "default")
if n.clickBase != "" {
req.Header.Set("Click", fmt.Sprintf("%s/admin/#%d", n.clickBase, rep.RepeaterID))
}
n.setAuth(req)
resp, err := n.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("ntfy returned HTTP %d", resp.StatusCode)
}
return nil
}
// setAuth uses basic auth when a username is configured (ntfy accepts an
// access token as the password), and bearer auth for a token on its own.
func (n *ntfyNotifier) setAuth(req *http.Request) {
switch {
case n.user != "" && n.token != "":
creds := base64.StdEncoding.EncodeToString([]byte(n.user + ":" + n.token))
req.Header.Set("Authorization", "Basic "+creds)
case n.token != "":
req.Header.Set("Authorization", "Bearer "+n.token)
}
}

346
backend/reports.go Normal file
View file

@ -0,0 +1,346 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
)
// maxReportMessageLen is the maximum length of the free-text message a user
// can attach to a report.
const maxReportMessageLen = 300
// reportTypeLabels maps the report types accepted by the API to their
// human-readable label used in notifications.
var reportTypeLabels = map[string]string{
"offline": "Repeater is offline",
"frequency": "Frequency is incorrect",
"callsign": "Callsign has changed",
"other": "Other",
}
func reportTypeLabel(t string) string {
if label, ok := reportTypeLabels[t]; ok {
return label
}
return t
}
type UserReport struct {
ID int64 `json:"id"`
CreatedAt string `json:"created_at"`
RepeaterID int `json:"repeater_id"`
Callsign string `json:"callsign"`
ReportType string `json:"report_type"`
Message string `json:"message"`
Status string `json:"status"`
ResolvedAt *string `json:"resolved_at"`
}
type reportsResponse struct {
Reports []UserReport `json:"reports"`
Total int `json:"total"`
Open int `json:"open"`
}
// === Public API ===
func handleCreateReport(db *sql.DB, notifier *ntfyNotifier) http.HandlerFunc {
limiter := newIPRateLimiter(5, time.Hour)
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var payload struct {
RepeaterID int `json:"repeater_id"`
ReportType string `json:"report_type"`
Message string `json:"message"`
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if _, ok := reportTypeLabels[payload.ReportType]; !ok {
http.Error(w, `{"error":"invalid report type"}`, http.StatusBadRequest)
return
}
message := strings.TrimSpace(payload.Message)
if utf8.RuneCountInString(message) > maxReportMessageLen {
http.Error(w, `{"error":"message too long"}`, http.StatusBadRequest)
return
}
if payload.ReportType == "other" && message == "" {
http.Error(w, `{"error":"message required"}`, http.StatusBadRequest)
return
}
rpt, err := queryRepeaterByID(db, payload.RepeaterID)
if err != nil || rpt == nil {
http.Error(w, `{"error":"repeater not found"}`, http.StatusNotFound)
return
}
// Only accepted reports count against the quota, so a user who mistypes
// the form does not lock themselves out.
if !limiter.allow(clientIP(r)) {
http.Error(w, `{"error":"too many reports, please try again later"}`, http.StatusTooManyRequests)
return
}
report := UserReport{
RepeaterID: rpt.ID,
Callsign: rpt.Callsign,
ReportType: payload.ReportType,
Message: message,
Status: "open",
}
var createdAt time.Time
err = db.QueryRow(
`INSERT INTO user_reports (repeater_id, callsign, report_type, message)
VALUES ($1, $2, $3, $4) RETURNING id, created_at`,
report.RepeaterID, report.Callsign, report.ReportType, report.Message,
).Scan(&report.ID, &createdAt)
if err != nil {
log.Printf("reports: insert failed for repeater %d: %v", report.RepeaterID, err)
http.Error(w, `{"error":"could not store report"}`, http.StatusInternalServerError)
return
}
report.CreatedAt = createdAt.Format("2006-01-02 15:04:05")
insertChangelog(db, ChangelogEntry{
RepeaterID: report.RepeaterID,
Callsign: report.Callsign,
Source: "user",
Action: "report",
Description: "User report: " + reportTypeLabel(report.ReportType),
NewValues: map[string]interface{}{
"report_type": report.ReportType,
"message": report.Message,
},
})
notifier.notifyReport(report)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"ok":true,"id":%d}`, report.ID)
}
}
// === Admin API ===
func handleAdminReports(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
status := r.URL.Query().Get("status")
if status != "open" && status != "resolved" {
status = ""
}
limit := 200
if v := r.URL.Query().Get("limit"); v != "" {
if p, err := strconv.Atoi(v); err == nil && p > 0 && p <= 500 {
limit = p
}
}
query := `SELECT id, created_at, repeater_id, callsign, report_type, message, status, resolved_at
FROM user_reports`
args := []interface{}{}
if status != "" {
query += ` WHERE status = $1`
args = append(args, status)
}
query += ` ORDER BY created_at DESC LIMIT ` + strconv.Itoa(limit)
rows, err := db.Query(query, args...)
if err != nil {
log.Printf("admin reports: query failed: %v", err)
http.Error(w, `{"error":"query failed"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
reports := []UserReport{}
for rows.Next() {
var rep UserReport
var createdAt time.Time
var resolvedAt *time.Time
if err := rows.Scan(&rep.ID, &createdAt, &rep.RepeaterID, &rep.Callsign,
&rep.ReportType, &rep.Message, &rep.Status, &resolvedAt); err != nil {
continue
}
rep.CreatedAt = createdAt.Format("2006-01-02 15:04:05")
if resolvedAt != nil {
formatted := resolvedAt.Format("2006-01-02 15:04:05")
rep.ResolvedAt = &formatted
}
reports = append(reports, rep)
}
var total, open int
db.QueryRow(`SELECT COUNT(*), COUNT(*) FILTER (WHERE status = 'open') FROM user_reports`).Scan(&total, &open)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(reportsResponse{Reports: reports, Total: total, Open: open})
}
}
func handleAdminUpdateReport(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var payload struct {
ID int64 `json:"id"`
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if payload.ID <= 0 {
http.Error(w, `{"error":"missing report id"}`, http.StatusBadRequest)
return
}
if payload.Status != "open" && payload.Status != "resolved" {
http.Error(w, `{"error":"status must be open or resolved"}`, http.StatusBadRequest)
return
}
var resolvedAt interface{}
if payload.Status == "resolved" {
resolvedAt = time.Now()
}
_, err := db.Exec(`UPDATE user_reports SET status = $1, resolved_at = $2 WHERE id = $3`,
payload.Status, resolvedAt, payload.ID)
if err != nil {
log.Printf("admin reports: status update failed for %d: %v", payload.ID, err)
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
}
func handleAdminDeleteReport(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
if err != nil || id <= 0 {
http.Error(w, `{"error":"missing or invalid id"}`, http.StatusBadRequest)
return
}
if _, err := db.Exec(`DELETE FROM user_reports WHERE id = $1`, id); err != nil {
log.Printf("admin reports: delete failed for %d: %v", id, err)
http.Error(w, `{"error":"delete failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
}
// === Rate limiting ===
// ipRateLimiter allows at most `limit` events per client IP within a sliding
// window. It keeps state in memory only — restarts reset all counters.
type ipRateLimiter struct {
mu sync.Mutex
hits map[string][]time.Time
limit int
window time.Duration
lastGC time.Time
}
func newIPRateLimiter(limit int, window time.Duration) *ipRateLimiter {
return &ipRateLimiter{
hits: make(map[string][]time.Time),
limit: limit,
window: window,
lastGC: time.Now(),
}
}
func (l *ipRateLimiter) allow(ip string) bool {
now := time.Now()
cutoff := now.Add(-l.window)
l.mu.Lock()
defer l.mu.Unlock()
if now.Sub(l.lastGC) > l.window {
for key, times := range l.hits {
if len(prune(times, cutoff)) == 0 {
delete(l.hits, key)
}
}
l.lastGC = now
}
recent := prune(l.hits[ip], cutoff)
if len(recent) >= l.limit {
l.hits[ip] = recent
return false
}
l.hits[ip] = append(recent, now)
return true
}
func prune(times []time.Time, cutoff time.Time) []time.Time {
kept := times[:0]
for _, t := range times {
if t.After(cutoff) {
kept = append(kept, t)
}
}
return kept
}
// clientIP returns the caller's IP, honouring the forwarding headers set by a
// reverse proxy. Used for rate limiting only — never stored.
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if first, _, found := strings.Cut(fwd, ","); found {
return strings.TrimSpace(first)
}
return strings.TrimSpace(fwd)
}
if real := r.Header.Get("X-Real-IP"); real != "" {
return strings.TrimSpace(real)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}

View file

@ -31,6 +31,11 @@ services:
- ADMIN_TOKEN=adminparty - ADMIN_TOKEN=adminparty
- STATIC_DIR=/app/frontend/static - STATIC_DIR=/app/frontend/static
- MIGRATIONS_DIR=/app/backend/migrations - MIGRATIONS_DIR=/app/backend/migrations
- NTFY_URL=${NTFY_URL:-}
- NTFY_TOPIC=${NTFY_TOPIC:-}
- NTFY_USER=${NTFY_USER:-}
- NTFY_TOKEN=${NTFY_TOKEN:-}
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:8080}
volumes: volumes:
go-mod-cache: go-mod-cache:

View file

@ -20,6 +20,11 @@ services:
- LISTEN_ADDR=:8080 - LISTEN_ADDR=:8080
- BM_SYNC=true - BM_SYNC=true
- ADMIN_TOKEN=${ADMIN_TOKEN:-} - ADMIN_TOKEN=${ADMIN_TOKEN:-}
- NTFY_URL=${NTFY_URL:-}
- NTFY_TOPIC=${NTFY_TOPIC:-}
- NTFY_USER=${NTFY_USER:-}
- NTFY_TOKEN=${NTFY_TOKEN:-}
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-}
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View file

@ -6,6 +6,7 @@ import { buildChannel, generateCpsXml, generateContactsCsv, CPS_CSV_HEADER, CPS_
import { generateAnytoneContactsCsv, generateAnytoneChannelsCsv, ANYTONE_CH_HEADER } from "./cps-anytone"; import { generateAnytoneContactsCsv, generateAnytoneChannelsCsv, ANYTONE_CH_HEADER } from "./cps-anytone";
import { getSelectedBand as _getSelectedBand, getSelectedNetworks as _getSelectedNetworks } from "./filters"; import { getSelectedBand as _getSelectedBand, getSelectedNetworks as _getSelectedNetworks } from "./filters";
import { buildPopup as _buildPopup } from "./popup"; import { buildPopup as _buildPopup } from "./popup";
import { validateReport } from "./report";
(function () { (function () {
"use strict"; "use strict";
@ -88,6 +89,20 @@ import { buildPopup as _buildPopup } from "./popup";
var cpsAtTgBtn = document.getElementById("cps-at-tg-btn"); var cpsAtTgBtn = document.getElementById("cps-at-tg-btn");
var cpsAtChBtn = document.getElementById("cps-at-ch-btn"); var cpsAtChBtn = document.getElementById("cps-at-ch-btn");
// === Report DOM + State ===
var reportModal = document.getElementById("report-modal");
var reportModalClose = document.getElementById("report-modal-close");
var reportModalBackdrop = document.getElementById("report-modal-backdrop");
var reportTargetEl = document.getElementById("report-target");
var reportMessage = document.getElementById("report-message");
var reportCharCount = document.getElementById("report-char-count");
var reportFeedback = document.getElementById("report-feedback");
var reportCancelBtn = document.getElementById("report-cancel");
var reportSubmitBtn = document.getElementById("report-submit");
var reportRepeaterId = 0;
var reportCloseTimer = null;
var cpsRepeaters = []; var cpsRepeaters = [];
var cpsActiveRepeaters = {}; var cpsActiveRepeaters = {};
var cpsTalkgroups = []; var cpsTalkgroups = [];
@ -502,6 +517,68 @@ import { buildPopup as _buildPopup } from "./popup";
// === Popup === // === Popup ===
function buildPopup(r) { return _buildPopup(r, t); } function buildPopup(r) { return _buildPopup(r, t); }
// === Report incorrect data ===
function openReportModal(id, callsign) {
clearTimeout(reportCloseTimer);
reportRepeaterId = id;
reportTargetEl.textContent = callsign ? callsign + " (#" + id + ")" : "#" + id;
reportMessage.value = "";
reportCharCount.textContent = "0";
reportFeedback.style.display = "none";
reportFeedback.className = "report-feedback";
reportSubmitBtn.disabled = false;
var defaultType = reportModal.querySelector('input[name="report-type"][value="offline"]');
if (defaultType) defaultType.checked = true;
reportModal.style.display = "flex";
translateDOM();
}
function closeReportModal() {
clearTimeout(reportCloseTimer);
reportModal.style.display = "none";
reportRepeaterId = 0;
}
function showReportFeedback(msg, success) {
reportFeedback.textContent = msg;
reportFeedback.className = success ? "report-feedback success" : "report-feedback";
reportFeedback.style.display = "";
}
function submitReport() {
var typeEl = reportModal.querySelector('input[name="report-type"]:checked');
var result = validateReport(reportRepeaterId, typeEl ? typeEl.value : "", reportMessage.value);
if (!result.ok) {
showReportFeedback(t(result.errorKey), false);
return;
}
reportSubmitBtn.disabled = true;
reportSubmitBtn.textContent = t("report_sending");
fetch("/api/reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(result.payload),
})
.then(function (resp) {
if (resp.status === 429) throw new Error("rate_limited");
if (!resp.ok) throw new Error("HTTP " + resp.status);
return resp.json();
})
.then(function () {
showReportFeedback(t("report_thanks"), true);
reportSubmitBtn.textContent = t("report_submit");
reportCloseTimer = setTimeout(closeReportModal, 2000);
})
.catch(function (err) {
console.error("Report error:", err);
showReportFeedback(err.message === "rate_limited" ? t("report_rate_limited") : t("report_error"), false);
reportSubmitBtn.disabled = false;
reportSubmitBtn.textContent = t("report_submit");
});
}
// === Display markers === // === Display markers ===
function displayRepeaters(repeaters) { function displayRepeaters(repeaters) {
markerLayer.clearLayers(); markerLayer.clearLayers();
@ -1125,9 +1202,30 @@ import { buildPopup as _buildPopup } from "./popup";
fetchRepeaters(); fetchRepeaters();
}); });
// === Report Events ===
document.addEventListener("click", function (e) {
var btn = e.target && e.target.closest ? e.target.closest(".rptr-report-btn") : null;
if (!btn) return;
e.preventDefault();
openReportModal(parseInt(btn.getAttribute("data-report-id"), 10), btn.getAttribute("data-report-callsign"));
});
reportModalClose.addEventListener("click", closeReportModal);
reportModalBackdrop.addEventListener("click", closeReportModal);
reportCancelBtn.addEventListener("click", closeReportModal);
reportSubmitBtn.addEventListener("click", submitReport);
reportMessage.addEventListener("input", function () {
reportCharCount.textContent = String(Array.from(reportMessage.value).length);
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && reportModal.style.display !== "none") {
closeReportModal();
}
});
// === CPS Studio Events === // === CPS Studio Events ===
cpsModalClose.addEventListener("click", closeCpsModal); cpsModalClose.addEventListener("click", closeCpsModal);
document.querySelector(".cps-modal-backdrop").addEventListener("click", closeCpsModal); cpsModal.querySelector(".cps-modal-backdrop").addEventListener("click", closeCpsModal);
document.addEventListener("keydown", function (e) { document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && cpsModal.style.display !== "none") { if (e.key === "Escape" && cpsModal.style.display !== "none") {
if (cpsAcList.children.length) { if (cpsAcList.children.length) {

View file

@ -86,6 +86,19 @@ describe("buildPopup", () => {
expect(html).toContain("&lt;script&gt;"); expect(html).toContain("&lt;script&gt;");
}); });
it("contains a report button carrying the repeater id and callsign", () => {
const html = buildPopup(makeRepeater(), mockT);
expect(html).toContain('class="rptr-report-btn"');
expect(html).toContain('data-report-id="1"');
expect(html).toContain('data-report-callsign="DB0ABC"');
expect(html).toContain("report_btn");
});
it("escapes the callsign in the report button attribute", () => {
const html = buildPopup(makeRepeater({ callsign: 'X" onclick="evil()' }), mockT);
expect(html).toContain('data-report-callsign="X&quot; onclick=&quot;evil()"');
});
it("uses translate function for labels", () => { it("uses translate function for labels", () => {
const keys: string[] = []; const keys: string[] = [];
const captureT = (key: string) => { keys.push(key); return key; }; const captureT = (key: string) => { keys.push(key); return key; };

View file

@ -51,6 +51,11 @@ export function buildPopup(r: Repeater, t: TranslateFunction): string {
html += "<tr><td>" + t("popup_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">' + t("popup_inactive") + '</td></tr>'; html += '<tr><td></td><td style="color:#ef5350;font-weight:600">' + t("popup_inactive") + '</td></tr>';
html += "</table></div>"; html += "</table>";
html +=
'<button type="button" class="rptr-report-btn" data-report-id="' + r.id +
'" data-report-callsign="' + escapeHtml(r.callsign) + '">' +
t("report_btn") + "</button>";
html += "</div>";
return html; return html;
} }

View file

@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { validateReport, REPORT_TYPES, REPORT_MAX_MESSAGE } from "./report";
describe("validateReport", () => {
it("accepts a known type without a message", () => {
const result = validateReport(262001, "offline", "");
expect(result).toEqual({
ok: true,
payload: { repeater_id: 262001, report_type: "offline", message: "" },
});
});
it("accepts every supported report type", () => {
REPORT_TYPES.forEach((type) => {
expect(validateReport(1, type, "details").ok).toBe(true);
});
});
it("trims the message", () => {
const result = validateReport(1, "frequency", " TX is 439.5125 ");
expect(result).toEqual({
ok: true,
payload: { repeater_id: 1, report_type: "frequency", message: "TX is 439.5125" },
});
});
it("rejects an unknown report type", () => {
expect(validateReport(1, "spam", "hi")).toEqual({ ok: false, errorKey: "report_err_type" });
});
it("rejects a missing repeater id", () => {
expect(validateReport(0, "offline", "")).toEqual({ ok: false, errorKey: "report_err_repeater" });
});
it("requires a message for the 'other' type", () => {
expect(validateReport(1, "other", " ")).toEqual({ ok: false, errorKey: "report_err_required" });
expect(validateReport(1, "other", "antenna was replaced").ok).toBe(true);
});
it("rejects messages longer than the limit", () => {
const tooLong = "a".repeat(REPORT_MAX_MESSAGE + 1);
expect(validateReport(1, "offline", tooLong)).toEqual({ ok: false, errorKey: "report_err_too_long" });
expect(validateReport(1, "offline", "a".repeat(REPORT_MAX_MESSAGE)).ok).toBe(true);
});
it("counts unicode characters, not UTF-16 code units", () => {
const emoji = "📻".repeat(REPORT_MAX_MESSAGE);
expect(validateReport(1, "offline", emoji).ok).toBe(true);
expect(validateReport(1, "offline", emoji + "📻").ok).toBe(false);
});
});

41
frontend/src/report.ts Normal file
View file

@ -0,0 +1,41 @@
/** User reports about incorrect repeater data. Mirrors backend/reports.go. */
export const REPORT_TYPES = ["offline", "frequency", "callsign", "other"] as const;
export type ReportType = (typeof REPORT_TYPES)[number];
/** Maximum length of the free-text message, enforced by the backend too. */
export const REPORT_MAX_MESSAGE = 300;
export interface ReportPayload {
repeater_id: number;
report_type: string;
message: string;
}
export type ReportValidation =
| { ok: true; payload: ReportPayload }
| { ok: false; errorKey: string };
/** Validate a report before sending it; errorKey is an i18n key. */
export function validateReport(repeaterId: number, reportType: string, message: string): ReportValidation {
const trimmed = (message || "").trim();
if (!repeaterId || repeaterId <= 0) {
return { ok: false, errorKey: "report_err_repeater" };
}
if ((REPORT_TYPES as readonly string[]).indexOf(reportType) === -1) {
return { ok: false, errorKey: "report_err_type" };
}
if (Array.from(trimmed).length > REPORT_MAX_MESSAGE) {
return { ok: false, errorKey: "report_err_too_long" };
}
if (reportType === "other" && trimmed === "") {
return { ok: false, errorKey: "report_err_required" };
}
return {
ok: true,
payload: { repeater_id: repeaterId, report_type: reportType, message: trimmed },
};
}

View file

@ -683,6 +683,100 @@
.history-changes .old-val { color: var(--tag-red-text); } .history-changes .old-val { color: var(--tag-red-text); }
.history-changes .new-val { color: var(--tag-green-text); } .history-changes .new-val { color: var(--tag-green-text); }
.history-empty { color: var(--text-faint); font-size: 13px; text-align: center; padding: 32px 0; } .history-empty { color: var(--text-faint); font-size: 13px; text-align: center; padding: 32px 0; }
.reports-badge {
display: inline-block;
min-width: 18px;
padding: 1px 5px;
margin-left: 6px;
border-radius: 9px;
background: var(--danger);
color: #fff;
font-size: 11px;
font-weight: 700;
text-align: center;
}
.reports-filter { display: flex; margin-right: auto; margin-left: 14px; }
.reports-filter-btn {
padding: 4px 10px;
border: 1px solid var(--border);
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
background: var(--bg);
cursor: pointer;
}
.reports-filter-btn:first-child { border-radius: 4px 0 0 4px; }
.reports-filter-btn:last-child { border-radius: 0 4px 4px 0; border-left: none; }
.reports-filter-btn.active { background: var(--tag-blue-bg); color: var(--tag-blue-text); border-color: var(--tag-blue-text); }
.report-entry {
border-bottom: 1px solid var(--border-light);
padding: 12px 0;
}
.report-entry:last-child { border-bottom: none; }
.report-entry.resolved { opacity: 0.55; }
.report-entry-meta {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 6px;
}
.report-type-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
background: var(--tag-gray-bg);
color: var(--tag-gray-text);
}
.report-type-offline { background: var(--tag-red-bg); color: var(--tag-red-text); }
.report-type-frequency { background: var(--tag-blue-bg); color: var(--tag-blue-text); }
.report-type-callsign { background: var(--tag-green-bg); color: var(--tag-green-text); }
.report-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
background: var(--tag-gray-bg);
color: var(--tag-gray-text);
}
.report-status-open { background: var(--tag-red-bg); color: var(--tag-red-text); }
.report-rptr-btn {
background: none;
border: none;
padding: 0;
font-size: 13px;
font-weight: 700;
color: var(--tag-blue-text);
cursor: pointer;
}
.report-rptr-btn:hover { text-decoration: underline; }
.report-msg {
font-size: 13px;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 8px;
}
.report-actions { display: flex; gap: 8px; }
.report-action-btn {
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
}
.report-action-btn:hover { background: var(--hover-bg); color: var(--text); }
.report-action-btn.danger { color: var(--danger); }
.report-action-btn.danger:hover { background: var(--tag-red-bg); }
</style> </style>
</head> </head>
<body> <body>
@ -702,6 +796,7 @@
<input type="text" class="search-input" id="search-input" placeholder="Search callsign, city, country, network..." /> <input type="text" class="search-input" id="search-input" placeholder="Search callsign, city, country, network..." />
<button class="topbar-btn" id="toggle-filters-btn">Filters</button> <button class="topbar-btn" id="toggle-filters-btn">Filters</button>
<button class="topbar-btn" id="fetch-all-bm-btn">Fetch All BM</button> <button class="topbar-btn" id="fetch-all-bm-btn">Fetch All BM</button>
<button class="topbar-btn" id="reports-btn">Reports<span class="reports-badge" id="reports-badge" style="display:none">0</span></button>
<span class="topbar-info" id="total-info"></span> <span class="topbar-info" id="total-info"></span>
<button class="logout-btn" id="logout-btn">Logout</button> <button class="logout-btn" id="logout-btn">Logout</button>
</div> </div>
@ -778,6 +873,19 @@
<div class="history-modal-body" id="history-body"></div> <div class="history-modal-body" id="history-body"></div>
</div> </div>
<div class="history-overlay" id="reports-overlay"></div>
<div class="history-modal" id="reports-modal">
<div class="history-modal-header">
<h3>User Reports</h3>
<div class="reports-filter">
<button class="reports-filter-btn active" data-report-filter="open">Open</button>
<button class="reports-filter-btn" data-report-filter="all">All</button>
</div>
<button class="history-modal-close" id="reports-close">&times;</button>
</div>
<div class="history-modal-body" id="reports-body"></div>
</div>
<script> <script>
(function () { (function () {
"use strict"; "use strict";
@ -838,6 +946,14 @@
var historyBody = document.getElementById("history-body"); var historyBody = document.getElementById("history-body");
var historyClose = document.getElementById("history-close"); var historyClose = document.getElementById("history-close");
var reportsBtn = document.getElementById("reports-btn");
var reportsBadge = document.getElementById("reports-badge");
var reportsOverlay = document.getElementById("reports-overlay");
var reportsModal = document.getElementById("reports-modal");
var reportsBody = document.getElementById("reports-body");
var reportsClose = document.getElementById("reports-close");
var reportsFilter = "open";
function escapeHtml(s) { function escapeHtml(s) {
if (!s) return ""; if (!s) return "";
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@ -852,6 +968,7 @@
loginWrap.style.display = "none"; loginWrap.style.display = "none";
adminWrap.style.display = "block"; adminWrap.style.display = "block";
fetchRepeaters(); fetchRepeaters();
refreshReportsBadge();
checkHash(); checkHash();
} }
@ -1619,6 +1736,131 @@
historyClose.addEventListener("click", closeHistory); historyClose.addEventListener("click", closeHistory);
historyOverlay.addEventListener("click", closeHistory); historyOverlay.addEventListener("click", closeHistory);
// === User reports ===
function refreshReportsBadge() {
apiFetch("/admin/api/reports?status=open&limit=1")
.then(function (data) {
if (data.open > 0) {
reportsBadge.textContent = data.open;
reportsBadge.style.display = "";
} else {
reportsBadge.style.display = "none";
}
})
.catch(function () {});
}
function showReports() {
reportsOverlay.style.display = "block";
reportsModal.classList.add("open");
loadReports();
}
function closeReports() {
reportsOverlay.style.display = "none";
reportsModal.classList.remove("open");
}
function loadReports() {
reportsBody.innerHTML = '<div class="history-empty">Loading...</div>';
var url = "/admin/api/reports";
if (reportsFilter === "open") url += "?status=open";
apiFetch(url)
.then(function (data) {
if (data.open > 0) {
reportsBadge.textContent = data.open;
reportsBadge.style.display = "";
} else {
reportsBadge.style.display = "none";
}
renderReports(data.reports);
})
.catch(function (err) {
if (err.message === "unauthorized") return;
reportsBody.innerHTML = '<div class="history-empty">Failed to load reports</div>';
});
}
function renderReports(reports) {
if (!reports.length) {
reportsBody.innerHTML = '<div class="history-empty">No reports</div>';
return;
}
var html = "";
for (var i = 0; i < reports.length; i++) {
var rep = reports[i];
var resolved = rep.status === "resolved";
html += '<div class="report-entry' + (resolved ? " resolved" : "") + '">';
html += '<div class="report-entry-meta">';
html += '<span class="history-date">' + escapeHtml(rep.created_at) + '</span>';
html += '<span class="report-type-badge report-type-' + escapeHtml(rep.report_type) + '">' + escapeHtml(rep.report_type) + '</span>';
html += '<span class="report-status report-status-' + escapeHtml(rep.status) + '">' + escapeHtml(rep.status) + '</span>';
html += '<button class="report-rptr-btn" data-repeater-id="' + rep.repeater_id + '">' +
escapeHtml(rep.callsign || "") + ' (#' + rep.repeater_id + ')</button>';
html += '</div>';
if (rep.message) {
html += '<div class="report-msg">' + escapeHtml(rep.message) + '</div>';
}
html += '<div class="report-actions">';
html += '<button class="report-action-btn" data-report-id="' + rep.id + '" data-report-action="' +
(resolved ? "open" : "resolved") + '">' + (resolved ? "Reopen" : "Mark resolved") + '</button>';
html += '<button class="report-action-btn danger" data-report-id="' + rep.id + '" data-report-action="delete">Delete</button>';
html += '</div>';
html += '</div>';
}
reportsBody.innerHTML = html;
}
reportsBody.addEventListener("click", function (e) {
var rptrBtn = e.target.closest(".report-rptr-btn");
if (rptrBtn) {
closeReports();
openDetailByID(parseInt(rptrBtn.getAttribute("data-repeater-id"), 10));
return;
}
var actionBtn = e.target.closest(".report-action-btn");
if (!actionBtn) return;
var id = parseInt(actionBtn.getAttribute("data-report-id"), 10);
var action = actionBtn.getAttribute("data-report-action");
actionBtn.disabled = true;
var req;
if (action === "delete") {
if (!confirm("Delete this report?")) {
actionBtn.disabled = false;
return;
}
req = apiFetch("/admin/api/reports/delete?id=" + id, { method: "DELETE" });
} else {
req = apiFetch("/admin/api/reports/status", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: id, status: action }),
});
}
req.then(loadReports).catch(function (err) {
if (err.message === "unauthorized") return;
actionBtn.disabled = false;
alert("Action failed");
});
});
document.querySelectorAll(".reports-filter-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
document.querySelectorAll(".reports-filter-btn").forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
reportsFilter = btn.getAttribute("data-report-filter");
loadReports();
});
});
reportsBtn.addEventListener("click", showReports);
reportsClose.addEventListener("click", closeReports);
reportsOverlay.addEventListener("click", closeReports);
detailClose.addEventListener("click", closeDetail); detailClose.addEventListener("click", closeDetail);
detailCancel.addEventListener("click", closeDetail); detailCancel.addEventListener("click", closeDetail);
detailOverlay.addEventListener("click", closeDetail); detailOverlay.addEventListener("click", closeDetail);
@ -1627,7 +1869,9 @@
document.addEventListener("keydown", function (e) { document.addEventListener("keydown", function (e) {
if (e.key === "Escape") { if (e.key === "Escape") {
if (historyModal.classList.contains("open")) { if (reportsModal.classList.contains("open")) {
closeReports();
} else if (historyModal.classList.contains("open")) {
closeHistory(); closeHistory();
} else if (detailPanel.classList.contains("open")) { } else if (detailPanel.classList.contains("open")) {
closeDetail(); closeDetail();

View file

@ -164,6 +164,35 @@
</div> </div>
</div> </div>
</div> </div>
<div id="report-modal" class="cps-modal" style="display:none">
<div class="cps-modal-backdrop" id="report-modal-backdrop"></div>
<div class="cps-modal-dialog report-modal-dialog">
<div class="cps-modal-header">
<h2 data-i18n="report_title">Report incorrect data</h2>
<button type="button" class="cps-modal-close" id="report-modal-close">&times;</button>
</div>
<div class="cps-modal-body">
<p class="cps-exp-note" data-i18n="report_intro">Something wrong with this repeater? Let us know — every report is reviewed manually.</p>
<div class="report-target" id="report-target"></div>
<div class="report-types">
<label class="report-type-label"><input type="radio" name="report-type" value="offline" checked> <span data-i18n="report_type_offline">Repeater is offline</span></label>
<label class="report-type-label"><input type="radio" name="report-type" value="frequency"> <span data-i18n="report_type_frequency">Frequency is incorrect</span></label>
<label class="report-type-label"><input type="radio" name="report-type" value="callsign"> <span data-i18n="report_type_callsign">Callsign has changed</span></label>
<label class="report-type-label"><input type="radio" name="report-type" value="other"> <span data-i18n="report_type_other">Other</span></label>
</div>
<label class="report-message-label" for="report-message" data-i18n="report_message_label">Message (optional)</label>
<textarea id="report-message" class="report-message" rows="4" maxlength="300" data-i18n-placeholder="report_message_placeholder" placeholder="Additional details (max. 300 characters)"></textarea>
<div class="report-counter"><span id="report-char-count">0</span>/300</div>
<div class="report-feedback" id="report-feedback" style="display:none"></div>
</div>
<div class="cps-modal-footer">
<div class="cps-step-buttons">
<button type="button" class="cps-btn-secondary" id="report-cancel" data-i18n="report_cancel">Cancel</button>
<button type="button" class="cps-btn-primary" id="report-submit" data-i18n="report_submit">Send report</button>
</div>
</div>
</div>
</div>
<script src="lib/i18next.min.js"></script> <script src="lib/i18next.min.js"></script>
<script src="lib/i18next-browser-languagedetector.min.js"></script> <script src="lib/i18next-browser-languagedetector.min.js"></script>
<script src="lib/i18next-http-backend.min.js"></script> <script src="lib/i18next-http-backend.min.js"></script>

View file

@ -62,5 +62,24 @@
"cps_at_step1_label": "Schritt 1: Talkgroups importieren", "cps_at_step1_label": "Schritt 1: Talkgroups importieren",
"cps_at_download_tg": "Talkgroups CSV herunterladen", "cps_at_download_tg": "Talkgroups CSV herunterladen",
"cps_at_step2_label": "Schritt 2: Kanäle importieren", "cps_at_step2_label": "Schritt 2: Kanäle importieren",
"cps_at_download_ch": "Kanäle CSV herunterladen" "cps_at_download_ch": "Kanäle CSV herunterladen",
"report_btn": "Fehler melden",
"report_title": "Falsche Daten melden",
"report_intro": "Stimmt etwas an diesem Relais nicht? Sag uns Bescheid. Jede Meldung wird manuell geprüft.",
"report_type_offline": "Relais ist außer Betrieb",
"report_type_frequency": "Frequenz ist falsch",
"report_type_callsign": "Rufzeichen hat sich geändert",
"report_type_other": "Sonstiges",
"report_message_label": "Nachricht (optional)",
"report_message_placeholder": "Weitere Details (max. 300 Zeichen)",
"report_cancel": "Abbrechen",
"report_submit": "Meldung senden",
"report_sending": "Wird gesendet...",
"report_thanks": "Danke! Deine Meldung wurde übermittelt.",
"report_error": "Meldung konnte nicht gesendet werden. Bitte später erneut versuchen.",
"report_rate_limited": "Zu viele Meldungen. Bitte später erneut versuchen.",
"report_err_repeater": "Unbekanntes Relais.",
"report_err_type": "Bitte wähle aus, was nicht stimmt.",
"report_err_required": "Bitte beschreibe das Problem.",
"report_err_too_long": "Nachricht ist zu lang (max. 300 Zeichen)."
} }

View file

@ -62,5 +62,24 @@
"cps_at_step1_label": "Step 1: Import talk groups", "cps_at_step1_label": "Step 1: Import talk groups",
"cps_at_download_tg": "Download Talkgroups CSV", "cps_at_download_tg": "Download Talkgroups CSV",
"cps_at_step2_label": "Step 2: Import channels", "cps_at_step2_label": "Step 2: Import channels",
"cps_at_download_ch": "Download Channels CSV" "cps_at_download_ch": "Download Channels CSV",
"report_btn": "Report an issue",
"report_title": "Report incorrect data",
"report_intro": "Something wrong with this repeater? Let us know. Every report is reviewed manually.",
"report_type_offline": "Repeater is offline",
"report_type_frequency": "Frequency is incorrect",
"report_type_callsign": "Callsign has changed",
"report_type_other": "Other",
"report_message_label": "Message (optional)",
"report_message_placeholder": "Additional details (max. 300 characters)",
"report_cancel": "Cancel",
"report_submit": "Send report",
"report_sending": "Sending...",
"report_thanks": "Thanks! Your report has been submitted.",
"report_error": "Could not submit report. Please try again later.",
"report_rate_limited": "Too many reports. Please try again later.",
"report_err_repeater": "Unknown repeater.",
"report_err_type": "Please choose what is wrong.",
"report_err_required": "Please describe the problem.",
"report_err_too_long": "Message is too long (max. 300 characters)."
} }

View file

@ -62,5 +62,24 @@
"cps_at_step1_label": "Paso 1: Importar grupos", "cps_at_step1_label": "Paso 1: Importar grupos",
"cps_at_download_tg": "Descargar grupos CSV", "cps_at_download_tg": "Descargar grupos CSV",
"cps_at_step2_label": "Paso 2: Importar canales", "cps_at_step2_label": "Paso 2: Importar canales",
"cps_at_download_ch": "Descargar canales CSV" "cps_at_download_ch": "Descargar canales CSV",
"report_btn": "Informar de un error",
"report_title": "Informar de datos incorrectos",
"report_intro": "¿Algo no está bien en este repetidor? Cuéntanoslo. Cada informe se revisa manualmente.",
"report_type_offline": "El repetidor está fuera de servicio",
"report_type_frequency": "La frecuencia es incorrecta",
"report_type_callsign": "El indicativo ha cambiado",
"report_type_other": "Otro",
"report_message_label": "Mensaje (opcional)",
"report_message_placeholder": "Detalles adicionales (máx. 300 caracteres)",
"report_cancel": "Cancelar",
"report_submit": "Enviar informe",
"report_sending": "Enviando...",
"report_thanks": "¡Gracias! Tu informe se ha enviado.",
"report_error": "No se pudo enviar el informe. Inténtalo de nuevo más tarde.",
"report_rate_limited": "Demasiados informes. Inténtalo de nuevo más tarde.",
"report_err_repeater": "Repetidor desconocido.",
"report_err_type": "Elige qué es lo que falla.",
"report_err_required": "Describe el problema.",
"report_err_too_long": "El mensaje es demasiado largo (máx. 300 caracteres)."
} }

View file

@ -62,5 +62,24 @@
"cps_at_step1_label": "\u00c9tape 1 : Importer les groupes", "cps_at_step1_label": "\u00c9tape 1 : Importer les groupes",
"cps_at_download_tg": "T\u00e9l\u00e9charger groupes CSV", "cps_at_download_tg": "T\u00e9l\u00e9charger groupes CSV",
"cps_at_step2_label": "\u00c9tape 2 : Importer les canaux", "cps_at_step2_label": "\u00c9tape 2 : Importer les canaux",
"cps_at_download_ch": "T\u00e9l\u00e9charger canaux CSV" "cps_at_download_ch": "T\u00e9l\u00e9charger canaux CSV",
"report_btn": "Signaler un probl\u00e8me",
"report_title": "Signaler des donn\u00e9es incorrectes",
"report_intro": "Un probl\u00e8me avec ce relais ? Dites-le-nous. Chaque signalement est v\u00e9rifi\u00e9 manuellement.",
"report_type_offline": "Le relais est hors service",
"report_type_frequency": "La fr\u00e9quence est incorrecte",
"report_type_callsign": "L'indicatif a chang\u00e9",
"report_type_other": "Autre",
"report_message_label": "Message (facultatif)",
"report_message_placeholder": "D\u00e9tails suppl\u00e9mentaires (max. 300 caract\u00e8res)",
"report_cancel": "Annuler",
"report_submit": "Envoyer le signalement",
"report_sending": "Envoi...",
"report_thanks": "Merci ! Votre signalement a \u00e9t\u00e9 envoy\u00e9.",
"report_error": "Impossible d'envoyer le signalement. R\u00e9essayez plus tard.",
"report_rate_limited": "Trop de signalements. R\u00e9essayez plus tard.",
"report_err_repeater": "Relais inconnu.",
"report_err_type": "Choisissez ce qui ne va pas.",
"report_err_required": "D\u00e9crivez le probl\u00e8me.",
"report_err_too_long": "Message trop long (max. 300 caract\u00e8res)."
} }

View file

@ -62,5 +62,24 @@
"cps_at_step1_label": "Passo 1: Importa gruppi", "cps_at_step1_label": "Passo 1: Importa gruppi",
"cps_at_download_tg": "Scarica gruppi CSV", "cps_at_download_tg": "Scarica gruppi CSV",
"cps_at_step2_label": "Passo 2: Importa canali", "cps_at_step2_label": "Passo 2: Importa canali",
"cps_at_download_ch": "Scarica canali CSV" "cps_at_download_ch": "Scarica canali CSV",
"report_btn": "Segnala un problema",
"report_title": "Segnala dati errati",
"report_intro": "C'è qualcosa che non va in questo ripetitore? Faccelo sapere. Ogni segnalazione viene verificata manualmente.",
"report_type_offline": "Il ripetitore è fuori servizio",
"report_type_frequency": "La frequenza è errata",
"report_type_callsign": "Il nominativo è cambiato",
"report_type_other": "Altro",
"report_message_label": "Messaggio (facoltativo)",
"report_message_placeholder": "Dettagli aggiuntivi (max. 300 caratteri)",
"report_cancel": "Annulla",
"report_submit": "Invia segnalazione",
"report_sending": "Invio...",
"report_thanks": "Grazie! La tua segnalazione è stata inviata.",
"report_error": "Impossibile inviare la segnalazione. Riprova più tardi.",
"report_rate_limited": "Troppe segnalazioni. Riprova più tardi.",
"report_err_repeater": "Ripetitore sconosciuto.",
"report_err_type": "Scegli cosa non va.",
"report_err_required": "Descrivi il problema.",
"report_err_too_long": "Messaggio troppo lungo (max. 300 caratteri)."
} }

View file

@ -64,5 +64,24 @@
"cps_at_step1_label": "Krok 1: Importuj grupy", "cps_at_step1_label": "Krok 1: Importuj grupy",
"cps_at_download_tg": "Pobierz grupy CSV", "cps_at_download_tg": "Pobierz grupy CSV",
"cps_at_step2_label": "Krok 2: Importuj kana\u0142y", "cps_at_step2_label": "Krok 2: Importuj kana\u0142y",
"cps_at_download_ch": "Pobierz kana\u0142y CSV" "cps_at_download_ch": "Pobierz kana\u0142y CSV",
"report_btn": "Zg\u0142o\u015b b\u0142\u0105d",
"report_title": "Zg\u0142o\u015b nieprawid\u0142owe dane",
"report_intro": "Co\u015b si\u0119 nie zgadza z tym przemiennikiem? Daj nam zna\u0107. Ka\u017cde zg\u0142oszenie sprawdzamy r\u0119cznie.",
"report_type_offline": "Przemiennik nie dzia\u0142a",
"report_type_frequency": "Cz\u0119stotliwo\u015b\u0107 jest nieprawid\u0142owa",
"report_type_callsign": "Znak wywo\u0142awczy si\u0119 zmieni\u0142",
"report_type_other": "Inne",
"report_message_label": "Wiadomo\u015b\u0107 (opcjonalnie)",
"report_message_placeholder": "Dodatkowe szczeg\u00f3\u0142y (maks. 300 znak\u00f3w)",
"report_cancel": "Anuluj",
"report_submit": "Wy\u015blij zg\u0142oszenie",
"report_sending": "Wysy\u0142anie...",
"report_thanks": "Dzi\u0119kujemy! Zg\u0142oszenie zosta\u0142o wys\u0142ane.",
"report_error": "Nie uda\u0142o si\u0119 wys\u0142a\u0107 zg\u0142oszenia. Spr\u00f3buj ponownie p\u00f3\u017aniej.",
"report_rate_limited": "Zbyt wiele zg\u0142osze\u0144. Spr\u00f3buj ponownie p\u00f3\u017aniej.",
"report_err_repeater": "Nieznany przemiennik.",
"report_err_type": "Wybierz, co jest nie tak.",
"report_err_required": "Opisz problem.",
"report_err_too_long": "Wiadomo\u015b\u0107 jest za d\u0142uga (maks. 300 znak\u00f3w)."
} }

View file

@ -1073,3 +1073,96 @@ html, body {
height: 100%; height: 100%;
} }
} }
/* Report Modal */
.rptr-report-btn {
display: block;
width: 100%;
margin-top: 10px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--clear-btn-bg);
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.rptr-report-btn:hover {
background: var(--clear-btn-hover);
color: var(--text);
}
.report-modal-dialog {
max-width: 460px;
}
.report-target {
font-size: 13px;
font-weight: 700;
color: var(--text);
margin-bottom: 10px;
}
.report-types {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.report-type-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text);
cursor: pointer;
}
.report-message-label {
display: block;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 5px;
}
.report-message {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--input-bg);
color: var(--text);
font-family: inherit;
font-size: 13px;
resize: vertical;
outline: none;
}
.report-message:focus {
border-color: #4CAF50;
}
.report-counter {
font-size: 11px;
color: var(--text-fainter);
text-align: right;
margin-top: 4px;
}
.report-feedback {
margin-top: 10px;
font-size: 12px;
font-weight: 600;
color: #ef5350;
}
.report-feedback.success {
color: #4CAF50;
}