Implement user reports
This commit is contained in:
parent
8f842a2df3
commit
14550b3797
21 changed files with 1191 additions and 10 deletions
14
README.md
14
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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/")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
backend/migrations/006_user_reports.sql
Normal file
18
backend/migrations/006_user_reports.sql
Normal 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
101
backend/ntfy.go
Normal 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
346
backend/reports.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,19 @@ describe("buildPopup", () => {
|
||||||
expect(html).toContain("<script>");
|
expect(html).toContain("<script>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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" onclick="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; };
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
frontend/src/report.test.ts
Normal file
51
frontend/src/report.test.ts
Normal 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
41
frontend/src/report.ts
Normal 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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">×</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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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">×</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>
|
||||||
|
|
|
||||||
|
|
@ -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)."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue