From 14550b37970500dac9c6d146190a1a8b37c61545 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Tue, 4 Aug 2026 14:33:00 +0200 Subject: [PATCH] Implement user reports --- README.md | 14 +- backend/main.go | 6 + backend/migrations/006_user_reports.sql | 18 ++ backend/ntfy.go | 101 +++++++ backend/reports.go | 346 ++++++++++++++++++++++++ compose.dev.yml | 5 + compose.yml | 5 + frontend/src/main.ts | 100 ++++++- frontend/src/popup.test.ts | 13 + frontend/src/popup.ts | 7 +- frontend/src/report.test.ts | 51 ++++ frontend/src/report.ts | 41 +++ frontend/static/admin.html | 246 ++++++++++++++++- frontend/static/index.html | 29 ++ frontend/static/locales/de.json | 21 +- frontend/static/locales/en.json | 21 +- frontend/static/locales/es.json | 21 +- frontend/static/locales/fr.json | 21 +- frontend/static/locales/it.json | 21 +- frontend/static/locales/pl.json | 21 +- frontend/static/style.css | 93 +++++++ 21 files changed, 1191 insertions(+), 10 deletions(-) create mode 100644 backend/migrations/006_user_reports.sql create mode 100644 backend/ntfy.go create mode 100644 backend/reports.go create mode 100644 frontend/src/report.test.ts create mode 100644 frontend/src/report.ts diff --git a/README.md b/README.md index 6974833..4a184ed 100644 --- a/README.md +++ b/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 - Detailed repeater popups (frequencies, color code, timeslots, hardware, power, antenna height, BrandMeister status) - 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 - Coordinate display with Maidenhead grid locator - 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` - **Frontend:** TypeScript (bundled with esbuild), Leaflet, i18next -- **Testing:** Vitest (92 unit tests) +- **Testing:** Vitest (115 unit tests) - **Infrastructure:** Docker Compose, multi-stage Dockerfile ## 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 | | [Nominatim](https://nominatim.openstreetmap.org) | Address geocoding and autocomplete (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. @@ -75,6 +77,13 @@ docker compose -f compose.test.yml run --rm test | `MIGRATIONS_DIR` | `migrations` | Path to SQL migration files | | `BM_SYNC` | *(disabled)* | Set to `true` to enable periodic BrandMeister device sync | | `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 @@ -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) | | GET | `/api/repeater` | Single repeater by ID (query: `id`) | | 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 @@ -94,6 +104,8 @@ docker compose -f compose.test.yml run --rm test │ ├── db.go # DB queries, Repeater struct, geo helpers │ ├── handlers.go # Public API handlers │ ├── 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 │ ├── seed.go # Database seeding from JSON dumps │ ├── migrate.go # Goose migration runner diff --git a/backend/main.go b/backend/main.go index b026770..d210470 100644 --- a/backend/main.go +++ b/backend/main.go @@ -36,12 +36,15 @@ func main() { log.Println("BM device sync disabled (set BM_SYNC=true to enable)") } + notifier := newNtfyNotifierFromEnv() + mux := http.NewServeMux() mux.HandleFunc("/api/repeaters", handleRepeaters(db)) mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(db)) mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db)) mux.HandleFunc("/api/repeater", handleRepeaterByID(db)) mux.HandleFunc("/api/repeaters/search", handleSearchRepeaters(db)) + mux.HandleFunc("/api/reports", handleCreateReport(db, notifier)) mux.HandleFunc("/api/version", handleVersion()) 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/remove-bm-tag", handleAdminRemoveBMTag(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)) log.Println("Admin interface enabled at /admin/") } diff --git a/backend/migrations/006_user_reports.sql b/backend/migrations/006_user_reports.sql new file mode 100644 index 0000000..90f2b74 --- /dev/null +++ b/backend/migrations/006_user_reports.sql @@ -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; diff --git a/backend/ntfy.go b/backend/ntfy.go new file mode 100644 index 0000000..2ff2a21 --- /dev/null +++ b/backend/ntfy.go @@ -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) + } +} diff --git a/backend/reports.go b/backend/reports.go new file mode 100644 index 0000000..82277d0 --- /dev/null +++ b/backend/reports.go @@ -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 +} diff --git a/compose.dev.yml b/compose.dev.yml index 715e20a..5d252e2 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -31,6 +31,11 @@ services: - ADMIN_TOKEN=adminparty - STATIC_DIR=/app/frontend/static - 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: go-mod-cache: diff --git a/compose.yml b/compose.yml index a274412..3d29453 100644 --- a/compose.yml +++ b/compose.yml @@ -20,6 +20,11 @@ services: - LISTEN_ADDR=:8080 - BM_SYNC=true - 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 volumes: diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 1c46a13..046590c 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -6,6 +6,7 @@ import { buildChannel, generateCpsXml, generateContactsCsv, CPS_CSV_HEADER, CPS_ import { generateAnytoneContactsCsv, generateAnytoneChannelsCsv, ANYTONE_CH_HEADER } from "./cps-anytone"; import { getSelectedBand as _getSelectedBand, getSelectedNetworks as _getSelectedNetworks } from "./filters"; import { buildPopup as _buildPopup } from "./popup"; +import { validateReport } from "./report"; (function () { "use strict"; @@ -88,6 +89,20 @@ import { buildPopup as _buildPopup } from "./popup"; var cpsAtTgBtn = document.getElementById("cps-at-tg-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 cpsActiveRepeaters = {}; var cpsTalkgroups = []; @@ -502,6 +517,68 @@ import { buildPopup as _buildPopup } from "./popup"; // === Popup === 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 === function displayRepeaters(repeaters) { markerLayer.clearLayers(); @@ -1125,9 +1202,30 @@ import { buildPopup as _buildPopup } from "./popup"; 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 === 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) { if (e.key === "Escape" && cpsModal.style.display !== "none") { if (cpsAcList.children.length) { diff --git a/frontend/src/popup.test.ts b/frontend/src/popup.test.ts index c91d998..7979c7f 100644 --- a/frontend/src/popup.test.ts +++ b/frontend/src/popup.test.ts @@ -86,6 +86,19 @@ describe("buildPopup", () => { 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", () => { const keys: string[] = []; const captureT = (key: string) => { keys.push(key); return key; }; diff --git a/frontend/src/popup.ts b/frontend/src/popup.ts index ff1f4a3..b943417 100644 --- a/frontend/src/popup.ts +++ b/frontend/src/popup.ts @@ -51,6 +51,11 @@ export function buildPopup(r: Repeater, t: TranslateFunction): string { html += "" + t("popup_antenna") + "" + r.agl + " m AGL"; if (r.inactive) html += '' + t("popup_inactive") + ''; - html += ""; + html += ""; + html += + '"; + html += ""; return html; } diff --git a/frontend/src/report.test.ts b/frontend/src/report.test.ts new file mode 100644 index 0000000..214c98b --- /dev/null +++ b/frontend/src/report.test.ts @@ -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); + }); +}); diff --git a/frontend/src/report.ts b/frontend/src/report.ts new file mode 100644 index 0000000..3b5f9ba --- /dev/null +++ b/frontend/src/report.ts @@ -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 }, + }; +} diff --git a/frontend/static/admin.html b/frontend/static/admin.html index be35a0a..7f652ac 100644 --- a/frontend/static/admin.html +++ b/frontend/static/admin.html @@ -683,6 +683,100 @@ .history-changes .old-val { color: var(--tag-red-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; } + + .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); } @@ -702,6 +796,7 @@ + @@ -778,6 +873,19 @@
+
+
+
+

User Reports

+
+ + +
+ +
+
+
+ diff --git a/frontend/static/locales/de.json b/frontend/static/locales/de.json index baf4c12..34fb272 100644 --- a/frontend/static/locales/de.json +++ b/frontend/static/locales/de.json @@ -62,5 +62,24 @@ "cps_at_step1_label": "Schritt 1: Talkgroups importieren", "cps_at_download_tg": "Talkgroups CSV herunterladen", "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)." } diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index 3a89da0..1632ce4 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -62,5 +62,24 @@ "cps_at_step1_label": "Step 1: Import talk groups", "cps_at_download_tg": "Download Talkgroups CSV", "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)." } diff --git a/frontend/static/locales/es.json b/frontend/static/locales/es.json index cd489ba..398b6a5 100644 --- a/frontend/static/locales/es.json +++ b/frontend/static/locales/es.json @@ -62,5 +62,24 @@ "cps_at_step1_label": "Paso 1: Importar grupos", "cps_at_download_tg": "Descargar grupos CSV", "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)." } diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index a0698d1..e87f3df 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -62,5 +62,24 @@ "cps_at_step1_label": "\u00c9tape 1 : Importer les groupes", "cps_at_download_tg": "T\u00e9l\u00e9charger groupes CSV", "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)." } diff --git a/frontend/static/locales/it.json b/frontend/static/locales/it.json index e96b0f8..c5ea8a9 100644 --- a/frontend/static/locales/it.json +++ b/frontend/static/locales/it.json @@ -62,5 +62,24 @@ "cps_at_step1_label": "Passo 1: Importa gruppi", "cps_at_download_tg": "Scarica gruppi CSV", "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)." } diff --git a/frontend/static/locales/pl.json b/frontend/static/locales/pl.json index 4a34e3c..cca509c 100644 --- a/frontend/static/locales/pl.json +++ b/frontend/static/locales/pl.json @@ -64,5 +64,24 @@ "cps_at_step1_label": "Krok 1: Importuj grupy", "cps_at_download_tg": "Pobierz grupy CSV", "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)." } diff --git a/frontend/static/style.css b/frontend/static/style.css index 8c985e7..674a1ca 100644 --- a/frontend/static/style.css +++ b/frontend/static/style.css @@ -1073,3 +1073,96 @@ html, body { 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; +}