mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
Primary region and details page cleanup
This commit is contained in:
@@ -46,7 +46,9 @@ func TestOrgConfigProfilesFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
body := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", sess))
|
||||
for _, want := range []string{"ESP32", "nRF52", "Mountains", "<select"} {
|
||||
// Profiles list with a selector; the geofenced region surfaces as the
|
||||
// click-to-preview location map (regions aren't listed individually).
|
||||
for _, want := range []string{"ESP32", "nRF52", "<select", "Preview a location", "region-map"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("config view missing %q", want)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ type configRegionView struct {
|
||||
DisplayName string
|
||||
Token string
|
||||
Layer int
|
||||
Primary bool
|
||||
GeofenceJSON string
|
||||
}
|
||||
|
||||
@@ -203,6 +204,7 @@ func (s *Handlers) parseRegions(r *http.Request, errs *[]string) ([]store.Region
|
||||
*errs = append(*errs, fmt.Sprintf("Too many regions (max %d).", maxConfigRegions))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
primaryTaken := false
|
||||
var ins []store.RegionInput
|
||||
var views []configRegionView
|
||||
for i := range tokens {
|
||||
@@ -210,10 +212,11 @@ func (s *Handlers) parseRegions(r *http.Request, errs *[]string) ([]store.Region
|
||||
display := strings.TrimSpace(formAt(r, "region_display", i))
|
||||
geojson := strings.TrimSpace(formAt(r, "region_geojson", i))
|
||||
layer, _ := strconv.Atoi(formAt(r, "region_layer", i))
|
||||
primary := formAt(r, "region_primary", i) == "1"
|
||||
if token == "" && display == "" && geojson == "" {
|
||||
continue // empty block
|
||||
}
|
||||
zv := configRegionView{DisplayName: display, Token: token, Layer: layer, GeofenceJSON: geojson}
|
||||
zv := configRegionView{DisplayName: display, Token: token, Layer: layer, Primary: primary, GeofenceJSON: geojson}
|
||||
if token == "" {
|
||||
*errs = append(*errs, "A region is missing its short name.")
|
||||
views = append(views, zv)
|
||||
@@ -239,8 +242,14 @@ func (s *Handlers) parseRegions(r *http.Request, errs *[]string) ([]store.Region
|
||||
views = append(views, zv)
|
||||
continue
|
||||
}
|
||||
// Only one region may be primary; keep the first and clear any later ones.
|
||||
if primary && primaryTaken {
|
||||
primary, zv.Primary = false, false
|
||||
} else if primary {
|
||||
primaryTaken = true
|
||||
}
|
||||
views = append(views, zv)
|
||||
ins = append(ins, store.RegionInput{Token: token, DisplayName: display, Layer: layer, GeofenceJSON: geofence})
|
||||
ins = append(ins, store.RegionInput{Token: token, DisplayName: display, Layer: layer, Primary: primary, GeofenceJSON: geofence})
|
||||
}
|
||||
return ins, views
|
||||
}
|
||||
@@ -354,7 +363,7 @@ func regionViews(regions []store.Region) []configRegionView {
|
||||
out := make([]configRegionView, 0, len(regions))
|
||||
for _, z := range regions {
|
||||
out = append(out, configRegionView{
|
||||
DisplayName: z.DisplayName, Token: z.Token, Layer: z.Layer,
|
||||
DisplayName: z.DisplayName, Token: z.Token, Layer: z.Layer, Primary: z.Primary,
|
||||
GeofenceJSON: string(z.GeofenceJSON),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +73,18 @@ function addBlock(kind) {
|
||||
document.getElementById(kind + 's').appendChild(proto.content.cloneNode(true));
|
||||
}
|
||||
function removeBlock(btn, cls) { btn.closest('.' + cls).remove(); }
|
||||
// Only one region may be primary: toggle the clicked one and clear the rest.
|
||||
function setPrimaryRegion(btn) {
|
||||
const block = btn.closest('.region-block');
|
||||
const makePrimary = block.querySelector('input[name="region_primary"]').value !== '1';
|
||||
document.querySelectorAll('#regions .region-block').forEach(function (b) {
|
||||
const on = b === block && makePrimary;
|
||||
b.querySelector('input[name="region_primary"]').value = on ? '1' : '';
|
||||
const pb = b.querySelector('.region-primary-btn');
|
||||
pb.classList.toggle('btn-primary', on);
|
||||
pb.textContent = on ? '★ Primary' : 'Make primary';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="/static/leaflet.js"></script>
|
||||
<script src="/static/leaflet-geoman.js"></script>
|
||||
@@ -106,7 +118,9 @@ function removeBlock(btn, cls) { btn.closest('.' + cls).remove(); }
|
||||
<div class="col-md-auto"><label class="form-label">Layer</label>
|
||||
<input class="form-control" type="number" name="region_layer" value="{{.Layer}}" style="width:5rem"></div>
|
||||
<div class="col-md-auto">
|
||||
<span class="badge bg-blue-lt region-shape-status">Applies everywhere</span>
|
||||
<input type="hidden" name="region_primary" value="{{if .Primary}}1{{end}}">
|
||||
<button type="button" class="btn btn-sm region-primary-btn{{if .Primary}} btn-primary{{end}}" onclick="setPrimaryRegion(this)">{{if .Primary}}★ Primary{{else}}Make primary{{end}}</button>
|
||||
<span class="badge bg-blue-lt region-shape-status ms-1">Applies everywhere</span>
|
||||
<button type="button" class="btn btn-sm btn-ghost-secondary region-clear ms-1">Clear area</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,7 @@ type Region struct {
|
||||
Token string
|
||||
DisplayName string
|
||||
Layer int
|
||||
Primary bool // the org's primary region (frames the config preview map)
|
||||
Geofence *geo.Shape
|
||||
GeofenceJSON []byte
|
||||
}
|
||||
@@ -65,6 +66,7 @@ type RegionInput struct {
|
||||
Token string
|
||||
DisplayName string
|
||||
Layer int
|
||||
Primary bool
|
||||
GeofenceJSON []byte
|
||||
}
|
||||
|
||||
@@ -126,14 +128,14 @@ func (s *Store) ListProfiles(ctx context.Context, orgID int64) ([]Profile, error
|
||||
// the order their tokens appear in a `region def` chain.
|
||||
func (s *Store) ListRegions(ctx context.Context, orgID int64) ([]Region, error) {
|
||||
rrows, err := s.pool.Query(ctx,
|
||||
`SELECT id, token, display_name, layer, geofence FROM config_regions WHERE org_id = $1 ORDER BY layer, token`, orgID)
|
||||
`SELECT id, token, display_name, layer, is_primary, geofence FROM config_regions WHERE org_id = $1 ORDER BY layer, token`, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list regions: %w", err)
|
||||
}
|
||||
regions, err := collectRows(rrows, func(r pgx.Row) (Region, error) {
|
||||
var z Region
|
||||
var raw []byte
|
||||
if err := r.Scan(&z.ID, &z.Token, &z.DisplayName, &z.Layer, &raw); err != nil {
|
||||
if err := r.Scan(&z.ID, &z.Token, &z.DisplayName, &z.Layer, &z.Primary, &raw); err != nil {
|
||||
return Region{}, err
|
||||
}
|
||||
if z.Geofence, err = geo.Parse(raw); err != nil {
|
||||
@@ -177,8 +179,8 @@ func (s *Store) ReplaceOrgConfig(ctx context.Context, orgID int64, profiles []Pr
|
||||
geofence = z.GeofenceJSON
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO config_regions (org_id, token, display_name, layer, geofence) VALUES ($1, $2, $3, $4, $5)`,
|
||||
orgID, z.Token, z.DisplayName, z.Layer, geofence); err != nil {
|
||||
`INSERT INTO config_regions (org_id, token, display_name, layer, is_primary, geofence) VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
orgID, z.Token, z.DisplayName, z.Layer, z.Primary, geofence); err != nil {
|
||||
return fmt.Errorf("insert region %q: %w", z.Token, err)
|
||||
}
|
||||
}
|
||||
@@ -236,20 +238,6 @@ func betterParent(a, b Region) bool {
|
||||
return a.Token < b.Token
|
||||
}
|
||||
|
||||
// RegionParentTokens returns each region's parent token ("" for a root), aligned
|
||||
// with regions, using the same overlap-based parentage as the region def chain —
|
||||
// for showing the derived hierarchy in the editor/read-only views.
|
||||
func RegionParentTokens(regions []Region) []string {
|
||||
parents := regionParents(regions)
|
||||
out := make([]string, len(regions))
|
||||
for i, p := range parents {
|
||||
if p != -1 {
|
||||
out[i] = regions[p].Token
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RegionDefCommands renders the regions that apply at (lat, lon) into the MeshCore
|
||||
// commands to run on a repeater: a single `region def …` line describing the
|
||||
// region tree for the location, followed by `region save`. The tree is the subset
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/jleight/meshtender/internal/geo"
|
||||
@@ -121,10 +120,4 @@ func TestRegionDefCommands(t *testing.T) {
|
||||
if got := RegionDefCommands(regions, ptr(-5.0), ptr(-5.0)); got != nil {
|
||||
t.Fatalf("outside: got %v, want nil", got)
|
||||
}
|
||||
|
||||
// Parentage is exposed for display, aligned with regions.
|
||||
wantParents := []string{"", "us", "us", "ny"}
|
||||
if got := RegionParentTokens(regions); !reflect.DeepEqual(got, wantParents) {
|
||||
t.Fatalf("parent tokens = %v, want %v", got, wantParents)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
-- A region can be marked primary; the config page frames its location-preview
|
||||
-- map on the primary region (falling back to the org's repeaters when none is set).
|
||||
ALTER TABLE config_regions ADD COLUMN is_primary BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE config_regions DROP COLUMN is_primary;
|
||||
+51
-38
@@ -19,38 +19,36 @@ type ProfileView struct {
|
||||
Steps []store.ConfigStep
|
||||
}
|
||||
|
||||
// RegionView is an org region rendered for display: its display name, MeshCore
|
||||
// token, and layer; the geofence reduced to its bounding box for the text summary
|
||||
// (empty when it matches everywhere); the raw GeoJSON for the map; plus whether it
|
||||
// applies at the previewed location.
|
||||
type RegionView struct {
|
||||
DisplayName string
|
||||
Token string
|
||||
Layer int
|
||||
Parent string // derived parent token ("" = root)
|
||||
MatchAll bool
|
||||
MinLat string
|
||||
MinLon string
|
||||
MaxLat string
|
||||
MaxLon string
|
||||
GeofenceJSON string
|
||||
Matches bool
|
||||
}
|
||||
|
||||
// ConfigView is an org's configuration for display: its named profiles (with one
|
||||
// selected for its base settings) and its regions (location steps), the two kept
|
||||
// independent. PreviewActive is true when a location was supplied.
|
||||
// selected for its base settings) and its regions, kept independent. The regions
|
||||
// themselves aren't listed here — they surface only through the location picker —
|
||||
// so this carries just what that map needs. PreviewActive is true when a location
|
||||
// was supplied.
|
||||
type ConfigView struct {
|
||||
HasConfig bool
|
||||
Profiles []ProfileView
|
||||
Selected string
|
||||
SelectedSteps []store.ConfigStep
|
||||
Regions []RegionView
|
||||
HasRegionShapes bool // any region has a drawn geofence (worth showing a map)
|
||||
RegionDef []string // region def/save commands for the previewed location
|
||||
HasRegions bool // org defines at least one region
|
||||
HasRegionShapes bool // some region has a geofence (so the picker map is useful)
|
||||
MapBounds []float64 // {minLat, minLon, maxLat, maxLon} framing all geofences, or nil
|
||||
RegionDef []string // region def/save commands for the previewed location
|
||||
PreviewActive bool
|
||||
}
|
||||
|
||||
// bbox accumulates a lat/lon bounding box. A nil *bbox is empty, so extend can be
|
||||
// chained from nil to fold in the first point/box.
|
||||
type bbox struct{ minLat, minLon, maxLat, maxLon float64 }
|
||||
|
||||
func (b *bbox) extend(minLat, minLon, maxLat, maxLon float64) *bbox {
|
||||
if b == nil {
|
||||
return &bbox{minLat, minLon, maxLat, maxLon}
|
||||
}
|
||||
b.minLat, b.minLon = min(b.minLat, minLat), min(b.minLon, minLon)
|
||||
b.maxLat, b.maxLon = max(b.maxLat, maxLat), max(b.maxLon, maxLon)
|
||||
return b
|
||||
}
|
||||
|
||||
// BuildConfigView loads an org's profiles and regions for read-only display.
|
||||
// selected names the profile whose base settings to show (falls back to the
|
||||
// first). lat/lon, when non-nil, mark which regions apply at that location. An
|
||||
@@ -66,6 +64,7 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected
|
||||
}
|
||||
cv := ConfigView{
|
||||
HasConfig: len(profiles) > 0 || len(regions) > 0,
|
||||
HasRegions: len(regions) > 0,
|
||||
PreviewActive: lat != nil && lon != nil,
|
||||
}
|
||||
for _, p := range profiles {
|
||||
@@ -82,21 +81,37 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected
|
||||
cv.Selected = profiles[idx].Name
|
||||
cv.SelectedSteps = profiles[idx].Steps
|
||||
}
|
||||
parentToks := store.RegionParentTokens(regions)
|
||||
for i, z := range regions {
|
||||
rv := RegionView{DisplayName: z.DisplayName, Token: z.Token, Layer: z.Layer, Parent: parentToks[i], GeofenceJSON: string(z.GeofenceJSON), Matches: store.RegionMatches(z, lat, lon)}
|
||||
if minLat, minLon, maxLat, maxLon, ok := z.Geofence.Bounds(); ok {
|
||||
rv.MinLat = formatCoord(minLat)
|
||||
rv.MinLon = formatCoord(minLon)
|
||||
rv.MaxLat = formatCoord(maxLat)
|
||||
rv.MaxLon = formatCoord(maxLon)
|
||||
} else {
|
||||
rv.MatchAll = true
|
||||
}
|
||||
if rv.GeofenceJSON != "" {
|
||||
// Frame the location-preview map. Prefer the primary region's geofence; if
|
||||
// none is set (or it has no shape), fall back to the org's public repeaters;
|
||||
// failing that, the union of all geofences. (The picker only renders when
|
||||
// HasRegionShapes, so a usable box almost always exists.)
|
||||
var union *bbox
|
||||
for _, z := range regions {
|
||||
if len(z.GeofenceJSON) > 0 {
|
||||
cv.HasRegionShapes = true
|
||||
}
|
||||
cv.Regions = append(cv.Regions, rv)
|
||||
if a, b, c, d, ok := z.Geofence.Bounds(); ok {
|
||||
union = union.extend(a, b, c, d)
|
||||
if z.Primary {
|
||||
cv.MapBounds = []float64{a, b, c, d}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cv.MapBounds == nil {
|
||||
if reps, err := st.ListPublicRepeaters(ctx, orgID); err == nil {
|
||||
var rb *bbox
|
||||
for _, rp := range reps {
|
||||
if rp.HasLocation {
|
||||
rb = rb.extend(rp.Lat, rp.Lon, rp.Lat, rp.Lon)
|
||||
}
|
||||
}
|
||||
if rb != nil {
|
||||
cv.MapBounds = []float64{rb.minLat, rb.minLon, rb.maxLat, rb.maxLon}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cv.MapBounds == nil && union != nil {
|
||||
cv.MapBounds = []float64{union.minLat, union.minLon, union.maxLat, union.maxLon}
|
||||
}
|
||||
if cv.PreviewActive {
|
||||
cv.RegionDef = store.RegionDefCommands(regions, lat, lon)
|
||||
@@ -104,8 +119,6 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected
|
||||
return cv, nil
|
||||
}
|
||||
|
||||
func formatCoord(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }
|
||||
|
||||
// OrgNav is the data the shared org-tabs sub-nav partial expects: the org slug,
|
||||
// which tab is active ("home" | "repeaters" | "members" | "config"), whether the
|
||||
// viewer is a member (the Members tab, which exposes personal info, only shows for
|
||||
|
||||
@@ -221,24 +221,37 @@
|
||||
|
||||
// ---- Read-only viewer ---------------------------------------------------
|
||||
|
||||
// regions: [{name, geojson}]; preview: {lat, lon} | null.
|
||||
window.regionMapView = function (mapId, regions, preview) {
|
||||
// regionMapView renders a location-picker map (no region outlines — those are
|
||||
// just noise here). opts:
|
||||
// pickURL — clicking the map navigates here with lat/lon appended so the
|
||||
// server resolves the region def for that point (must end "?"/"&");
|
||||
// preview — {lat, lon} of an already-picked point, shown as a marker;
|
||||
// bounds — [[minLat,minLon],[maxLat,maxLon]] to frame the org's regions.
|
||||
window.regionMapView = function (mapId, opts) {
|
||||
opts = opts || {};
|
||||
var map = L.map(mapId, { scrollWheelZoom: false });
|
||||
darkTiles(map);
|
||||
var layers = [];
|
||||
(regions || []).forEach(function (z, i) {
|
||||
var layer = layerFromGeoJSON(z.geojson);
|
||||
if (!layer) return;
|
||||
if (layer.setStyle) layer.setStyle(styleFor(i, false));
|
||||
layer.bindPopup(z.name).addTo(map);
|
||||
layers.push(layer);
|
||||
});
|
||||
if (preview) {
|
||||
var m = L.circleMarker([preview.lat, preview.lon], {
|
||||
if (opts.pickURL) {
|
||||
map.on("click", function (e) {
|
||||
window.location = opts.pickURL + "lat=" + e.latlng.lat.toFixed(6) + "&lon=" + e.latlng.lng.toFixed(6);
|
||||
});
|
||||
}
|
||||
var fit = [];
|
||||
if (opts.bounds) fit.push(opts.bounds[0], opts.bounds[1]);
|
||||
if (opts.preview) {
|
||||
L.circleMarker([opts.preview.lat, opts.preview.lon], {
|
||||
radius: 7, color: "#fff", weight: 2, fillColor: "#fff", fillOpacity: 0.9,
|
||||
}).addTo(map);
|
||||
layers.push(m);
|
||||
fit.push([opts.preview.lat, opts.preview.lon]);
|
||||
}
|
||||
if (fit.length) {
|
||||
var b = L.latLngBounds(fit);
|
||||
// A zero-size box (single point — one repeater, or a preview with no
|
||||
// region box) can't be fit; center on it at a neighborhood zoom instead.
|
||||
if (b.getNorthEast().equals(b.getSouthWest())) map.setView(b.getCenter(), 11, { animate: false });
|
||||
else map.fitBounds(b.pad(0.2), { animate: false });
|
||||
} else {
|
||||
map.setView([20, 0], 2, { animate: false });
|
||||
}
|
||||
fitToLayers(map, layers, preview ? [preview.lat, preview.lon] : null);
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -30,63 +30,20 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Config.Regions}}
|
||||
{{if .Config.PreviewActive}}
|
||||
<div class="card{{if .Config.Profiles}} mt-3{{end}}">
|
||||
<div class="card-header"><h3 class="card-title">Regions</h3>
|
||||
<span class="card-subtitle ms-auto">region hierarchy</span>
|
||||
</div>
|
||||
<div class="card-body text-secondary">
|
||||
A repeater is assigned to every region whose area contains it. Sorted by layer, their names form a <code>region def</code> command.
|
||||
</div>
|
||||
</div>
|
||||
{{if .Config.HasRegionShapes}}
|
||||
<div class="card mt-3">
|
||||
<div class="card-body p-2">
|
||||
<link rel="stylesheet" href="/static/leaflet.css">
|
||||
<div id="region-map" style="height:320px" role="region" aria-label="Map of this organization's regions"></div>
|
||||
<script src="/static/leaflet.js"></script>
|
||||
<script src="/static/regionmap.js"></script>
|
||||
<script>
|
||||
regionMapView('region-map', [
|
||||
{{range .Config.Regions}}{{if .GeofenceJSON}}{name: {{.DisplayName}}, geojson: {{.GeofenceJSON}}},
|
||||
{{end}}{{end}}
|
||||
], {{if .Config.PreviewActive}}{lat: {{.PreviewLat}}, lon: {{.PreviewLon}}}{{else}}null{{end}});
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Config.RegionDef}}
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">region def · at {{.PreviewLat}}, {{.PreviewLon}}</h3></div>
|
||||
<div class="card-header"><h3 class="card-title">Regions</h3></div>
|
||||
{{if .Config.RegionDef}}
|
||||
<div class="card-body"><pre class="mb-0"><code>{{range .Config.RegionDef}}{{.}}
|
||||
{{end}}</code></pre></div>
|
||||
</div>
|
||||
{{else if .Config.PreviewActive}}
|
||||
<div class="card mt-3"><div class="card-body text-secondary">No regions cover {{.PreviewLat}}, {{.PreviewLon}}.</div></div>
|
||||
{{end}}
|
||||
<div class="card mt-3">
|
||||
<div class="table-responsive">
|
||||
<table class="table card-table">
|
||||
<thead><tr><th>Display name</th><th>Name</th><th>Layer</th><th>Parent</th><th>Area</th>{{if .Config.PreviewActive}}<th></th>{{end}}</tr></thead>
|
||||
<tbody>
|
||||
{{range .Config.Regions}}
|
||||
<tr>
|
||||
<td>{{.DisplayName}}</td>
|
||||
<td><code>{{.Token}}</code></td>
|
||||
<td>{{.Layer}}</td>
|
||||
<td>{{if .Parent}}<code>{{.Parent}}</code>{{else}}<span class="text-secondary">root</span>{{end}}</td>
|
||||
<td class="text-secondary">{{if .MatchAll}}everywhere{{else}}lat {{.MinLat}}…{{.MaxLat}}, lon {{.MinLon}}…{{.MaxLon}}{{end}}</td>
|
||||
{{if $.Config.PreviewActive}}<td>{{if .Matches}}<span class="badge bg-success-lt">applies</span>{{end}}</td>{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card-body text-secondary">No regions cover this location.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Sidebar: profile selector (if profiles) + location preview (if regions) -->
|
||||
<!-- Sidebar: profile selector (if profiles) + a click-to-preview location map -->
|
||||
<div class="col-lg-4">
|
||||
{{if .Config.Profiles}}
|
||||
<div class="card">
|
||||
@@ -100,24 +57,30 @@
|
||||
{{if .PreviewLon}}<input type="hidden" name="lon" value="{{.PreviewLon}}">{{end}}
|
||||
</form>
|
||||
<p class="text-secondary small mt-2 mb-0">
|
||||
Pick the profile matching your hardware to see its base settings.{{if .Config.Regions}} A repeater's regions are assigned separately, by location.{{end}}
|
||||
Pick the profile matching your hardware to see its base settings.{{if .Config.HasRegions}} A repeater's regions are assigned separately, by location.{{end}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Config.Regions}}
|
||||
{{if .Config.HasRegionShapes}}
|
||||
<div class="card{{if .Config.Profiles}} mt-3{{end}}">
|
||||
<div class="card-header"><h3 class="card-title">Preview a location</h3></div>
|
||||
<div class="card-body">
|
||||
<form method="get" action="/orgs/{{.Org.Slug}}/config" class="row g-2 align-items-end">
|
||||
{{if .Config.Profiles}}<input type="hidden" name="profile" value="{{.Config.Selected}}">{{end}}
|
||||
<div class="col-12"><label class="form-label" for="lat">Latitude</label>
|
||||
<input class="form-control" type="text" id="lat" name="lat" value="{{if .PreviewLat}}{{.PreviewLat}}{{end}}"></div>
|
||||
<div class="col-12"><label class="form-label" for="lon">Longitude</label>
|
||||
<input class="form-control" type="text" id="lon" name="lon" value="{{if .PreviewLon}}{{.PreviewLon}}{{end}}"></div>
|
||||
<div class="col-12"><button class="btn w-100" type="submit">Resolve regions</button></div>
|
||||
</form>
|
||||
<div class="card-body p-2">
|
||||
<link rel="stylesheet" href="/static/leaflet.css">
|
||||
<div id="region-map" style="height:240px" role="region" aria-label="Click the map to preview a repeater's region config"></div>
|
||||
<script src="/static/leaflet.js"></script>
|
||||
<script src="/static/regionmap.js"></script>
|
||||
<script>
|
||||
regionMapView('region-map', {
|
||||
pickURL: '/orgs/{{.Org.Slug}}/config?{{if .Config.Profiles}}profile={{.Config.Selected | urlquery}}&{{end}}',
|
||||
{{if .Config.PreviewActive}}preview: {lat: {{.PreviewLat}}, lon: {{.PreviewLon}}},{{end}}
|
||||
{{if .Config.MapBounds}}bounds: [[{{index .Config.MapBounds 0}}, {{index .Config.MapBounds 1}}], [{{index .Config.MapBounds 2}}, {{index .Config.MapBounds 3}}]]{{end}}
|
||||
});
|
||||
</script>
|
||||
{{if .Config.PreviewActive}}
|
||||
<div class="text-secondary small mt-2 text-center">{{template "icon-map-pin" "me-1"}}<span class="font-monospace">{{.PreviewLat}}, {{.PreviewLon}}</span></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user