diff --git a/admin_map_source_routes.go b/admin_map_source_routes.go index 3461840..7da03a4 100644 --- a/admin_map_source_routes.go +++ b/admin_map_source_routes.go @@ -27,6 +27,18 @@ func registerMapSourceRoutes(r gin.IRouter, store *store) { } c.JSON(http.StatusOK, gin.H{"item": publicMapTileSourceDTO(*row)}) }) + r.GET("/map-source/enabled", func(c *gin.Context) { + rows, err := store.ListEnabledMapTileSources() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + items := make([]gin.H, 0, len(rows)) + for _, row := range rows { + items = append(items, publicMapTileSourceDTO(row)) + } + c.JSON(http.StatusOK, gin.H{"items": items}) + }) } func registerAdminMapSourceRoutes(r gin.IRouter, store *store) { diff --git a/map_source_store.go b/map_source_store.go index 493b8f0..6177296 100644 --- a/map_source_store.go +++ b/map_source_store.go @@ -52,6 +52,22 @@ func (s *store) CountMapTileSources(opts listOptions) (int64, error) { return total, s.db.Model(&mapTileSourceRecord{}).Count(&total).Error } +func (s *store) ListEnabledMapTileSources() ([]mapTileSourceRecord, error) { + var rows []mapTileSourceRecord + if err := s.db.Model(&mapTileSourceRecord{}). + Where("enabled = ?", true). + Order("is_default DESC"). + Order("updated_at DESC"). + Order("id DESC"). + Find(&rows).Error; err != nil { + return nil, err + } + if len(rows) == 0 { + return []mapTileSourceRecord{defaultMapTileSourceRecord()}, nil + } + return rows, nil +} + func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) { var row mapTileSourceRecord err := s.db.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Take(&row).Error diff --git a/map_source_store_test.go b/map_source_store_test.go index fe7aabf..dc2a5f7 100644 --- a/map_source_store_test.go +++ b/map_source_store_test.go @@ -33,6 +33,42 @@ func TestCreateMapTileSourceValidation(t *testing.T) { } } +func TestListEnabledMapTileSources(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + disabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false}) + if err != nil { + t.Fatalf("CreateMapTileSource(disabled) error = %v", err) + } + custom, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://custom.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true}) + if err != nil { + t.Fatalf("CreateMapTileSource(custom) error = %v", err) + } + if _, err := st.SetDefaultMapTileSource(custom.ID); err != nil { + t.Fatalf("SetDefaultMapTileSource() error = %v", err) + } + + rows, err := st.ListEnabledMapTileSources() + if err != nil { + t.Fatalf("ListEnabledMapTileSources() error = %v", err) + } + if len(rows) < 2 { + t.Fatalf("ListEnabledMapTileSources() length = %d, want at least 2", len(rows)) + } + if rows[0].ID != custom.ID { + t.Fatalf("first enabled source id = %d, want default %d", rows[0].ID, custom.ID) + } + for _, row := range rows { + if row.ID == disabled.ID { + t.Fatalf("disabled source was returned: %+v", row) + } + if !row.Enabled { + t.Fatalf("disabled row returned: %+v", row) + } + } +} + func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) { st := openTestStore(t) defer st.Close() diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index a6d706e..1913915 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -16,7 +16,7 @@ import HelpPage from './components/HelpPage.vue' import MeshMap from './components/MeshMap.vue' import NodeDetailedPage from './components/NodeDetailedPage.vue' import NodeListPanel from './components/NodeListPanel.vue' -import { fallbackMapSource, loadDefaultMapSource } from './mapSource' +import { fallbackMapSource, loadEnabledMapSources } from './mapSource' import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, NodeInfo, NodeInfoById, PositionRecord, PublicMapTileSource, TextMessage } from './types' const currentPath = window.location.pathname @@ -52,6 +52,7 @@ const currentMapBounds = ref(null) const currentMapZoom = ref(2) const mapReportsLoading = ref(false) const mapReportTotal = ref(0) +const mapSources = ref([fallbackMapSource]) const mapSource = ref(fallbackMapSource) const pendingDeleteAction = ref(null) type DeletableTextMessage = TextMessage & { mergedCount?: number; mergedMessages?: TextMessage[] } @@ -296,7 +297,16 @@ async function refresh(showLoading = true) { } async function loadMapSource() { - mapSource.value = await loadDefaultMapSource() + const sources = await loadEnabledMapSources() + mapSources.value = sources + mapSource.value = sources[0] ?? fallbackMapSource +} + +function selectMapSource(sourceId: number) { + const source = mapSources.value.find((item) => item.id === sourceId) + if (source) { + mapSource.value = source + } } async function checkAdminSession() { @@ -581,6 +591,8 @@ onBeforeUnmount(() => { :auto-fit="false" :loading="mapReportsLoading" :map-source="mapSource" + :map-sources="mapSources" + @map-source-change="selectMapSource" @bounds-change="handleMapBoundsChange" @select-node="selectedNodeId = $event" @clear-node="selectedNodeId = null" diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index 2f92faf..c03cb21 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -33,6 +33,7 @@ import type { NodeInfo, PositionRecord, PublicMapTileSourceResponse, + PublicMapTileSourcesResponse, TelemetryRecord, TextMessage, } from './types' @@ -133,6 +134,10 @@ export function getDefaultMapSource(): Promise { return getJSON('/api/map-source/default') } +export function getEnabledMapSources(): Promise { + return getJSON('/api/map-source/enabled') +} + export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise> { return getJSON>(listPath('/api/text-messages', limit, offset, nodeId)) } diff --git a/meshmap_frontend/src/components/MeshMap.vue b/meshmap_frontend/src/components/MeshMap.vue index d5190fd..c25b371 100644 --- a/meshmap_frontend/src/components/MeshMap.vue +++ b/meshmap_frontend/src/components/MeshMap.vue @@ -12,10 +12,12 @@ const props = withDefaults(defineProps<{ autoFit?: boolean loading?: boolean mapSource?: PublicMapTileSource + mapSources?: PublicMapTileSource[] }>(), { autoFit: true, loading: false, mapSource: () => fallbackMapSource, + mapSources: () => [fallbackMapSource], }) const emit = defineEmits<{ @@ -24,6 +26,7 @@ const emit = defineEmits<{ 'delete-node': [nodeId: string] 'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }] 'bounds-change': [payload: MapBoundsChangePayload] + 'map-source-change': [sourceId: number] }>() const mapEl = ref(null) @@ -92,6 +95,10 @@ watch( { deep: true }, ) +function selectMapSource(sourceId: number) { + emit('map-source-change', sourceId) +} + function applyTileLayer() { if (!map) { return @@ -392,6 +399,43 @@ function escapeHTML(value: string): string {