diff --git a/admin/app/controllers/maps_controller.ts b/admin/app/controllers/maps_controller.ts index 4a84307..f5fb0f3 100644 --- a/admin/app/controllers/maps_controller.ts +++ b/admin/app/controllers/maps_controller.ts @@ -1,4 +1,5 @@ import { MapService } from '#services/map_service' +import MapMarker from '#models/map_marker' import { assertNotPrivateUrl, downloadCollectionValidator, @@ -8,6 +9,7 @@ import { } from '#validators/common' import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' +import vine from '@vinejs/vine' @inject() export default class MapsController { @@ -123,4 +125,60 @@ export default class MapsController { message: 'Map file deleted successfully', } } + + // --- Map Markers --- + + async listMarkers({}: HttpContext) { + return await MapMarker.query().orderBy('created_at', 'asc') + } + + async createMarker({ request }: HttpContext) { + const payload = await request.validateUsing( + vine.compile( + vine.object({ + name: vine.string().trim().minLength(1).maxLength(255), + longitude: vine.number(), + latitude: vine.number(), + color: vine.string().trim().maxLength(20).optional(), + }) + ) + ) + const marker = await MapMarker.create({ + name: payload.name, + longitude: payload.longitude, + latitude: payload.latitude, + color: payload.color ?? 'orange', + }) + return marker + } + + async updateMarker({ request, response }: HttpContext) { + const { id } = request.params() + const marker = await MapMarker.find(id) + if (!marker) { + return response.status(404).send({ message: 'Marker not found' }) + } + const payload = await request.validateUsing( + vine.compile( + vine.object({ + name: vine.string().trim().minLength(1).maxLength(255).optional(), + color: vine.string().trim().maxLength(20).optional(), + }) + ) + ) + if (payload.name !== undefined) marker.name = payload.name + if (payload.color !== undefined) marker.color = payload.color + await marker.save() + return marker + } + + async deleteMarker({ request, response }: HttpContext) { + const { id } = request.params() + const marker = await MapMarker.find(id) + if (!marker) { + return response.status(404).send({ message: 'Marker not found' }) + } + await marker.delete() + return { message: 'Marker deleted' } + } } diff --git a/admin/app/models/map_marker.ts b/admin/app/models/map_marker.ts new file mode 100644 index 0000000..7d588fd --- /dev/null +++ b/admin/app/models/map_marker.ts @@ -0,0 +1,43 @@ +import { DateTime } from 'luxon' +import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm' + +export default class MapMarker extends BaseModel { + static namingStrategy = new SnakeCaseNamingStrategy() + + @column({ isPrimary: true }) + declare id: number + + @column() + declare name: string + + @column() + declare longitude: number + + @column() + declare latitude: number + + @column() + declare color: string + + // 'pin' for user-placed markers, 'waypoint' for route points (future) + @column() + declare marker_type: string + + // Groups markers into a route (future) + @column() + declare route_id: string | null + + // Order within a route (future) + @column() + declare route_order: number | null + + // Optional user notes for a location + @column() + declare notes: string | null + + @column.dateTime({ autoCreate: true }) + declare created_at: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updated_at: DateTime +} diff --git a/admin/database/migrations/1771200000001_create_map_markers_table.ts b/admin/database/migrations/1771200000001_create_map_markers_table.ts new file mode 100644 index 0000000..3268de4 --- /dev/null +++ b/admin/database/migrations/1771200000001_create_map_markers_table.ts @@ -0,0 +1,25 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'map_markers' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.string('name').notNullable() + table.double('longitude').notNullable() + table.double('latitude').notNullable() + table.string('color', 20).notNullable().defaultTo('orange') + table.string('marker_type', 20).notNullable().defaultTo('pin') + table.string('route_id').nullable() + table.integer('route_order').nullable() + table.text('notes').nullable() + table.timestamp('created_at') + table.timestamp('updated_at') + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/admin/inertia/components/maps/MapComponent.tsx b/admin/inertia/components/maps/MapComponent.tsx index a202538..5b53608 100644 --- a/admin/inertia/components/maps/MapComponent.tsx +++ b/admin/inertia/components/maps/MapComponent.tsx @@ -22,7 +22,7 @@ export default function MapComponent() { const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null) const [markerName, setMarkerName] = useState('') const [markerColor, setMarkerColor] = useState('orange') - const [selectedMarkerId, setSelectedMarkerId] = useState(null) + const [selectedMarkerId, setSelectedMarkerId] = useState(null) // Add the PMTiles protocol to maplibre-gl useEffect(() => { @@ -54,7 +54,7 @@ export default function MapComponent() { }, []) const handleDeleteMarker = useCallback( - (id: string) => { + (id: number) => { if (selectedMarkerId === id) setSelectedMarkerId(null) deleteMarker(id) }, diff --git a/admin/inertia/components/maps/MarkerPanel.tsx b/admin/inertia/components/maps/MarkerPanel.tsx index de1982b..1bf448e 100644 --- a/admin/inertia/components/maps/MarkerPanel.tsx +++ b/admin/inertia/components/maps/MarkerPanel.tsx @@ -5,10 +5,10 @@ import type { MapMarker } from '~/hooks/useMapMarkers' interface MarkerPanelProps { markers: MapMarker[] - onDelete: (id: string) => void + onDelete: (id: number) => void onFlyTo: (longitude: number, latitude: number) => void - onSelect: (id: string | null) => void - selectedMarkerId: string | null + onSelect: (id: number | null) => void + selectedMarkerId: number | null } export default function MarkerPanel({ diff --git a/admin/inertia/hooks/useMapMarkers.ts b/admin/inertia/hooks/useMapMarkers.ts index 509ce4e..ba999eb 100644 --- a/admin/inertia/hooks/useMapMarkers.ts +++ b/admin/inertia/hooks/useMapMarkers.ts @@ -1,4 +1,5 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect } from 'react' +import api from '~/lib/api' export const PIN_COLORS = [ { id: 'orange', label: 'Orange', hex: '#a84a12' }, @@ -12,7 +13,7 @@ export const PIN_COLORS = [ export type PinColorId = typeof PIN_COLORS[number]['id'] export interface MapMarker { - id: string + id: number name: string longitude: number latitude: number @@ -20,60 +21,66 @@ export interface MapMarker { createdAt: string } -const STORAGE_KEY = 'nomad:map-markers' - -function getInitialMarkers(): MapMarker[] { - try { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored) { - const parsed = JSON.parse(stored) - if (Array.isArray(parsed)) return parsed - } - } catch {} - return [] -} - -function persist(markers: MapMarker[]) { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(markers)) - } catch {} -} - export function useMapMarkers() { - const [markers, setMarkers] = useState(getInitialMarkers) + const [markers, setMarkers] = useState([]) + const [loaded, setLoaded] = useState(false) - const addMarker = useCallback((name: string, longitude: number, latitude: number, color: PinColorId = 'orange'): MapMarker => { - const marker: MapMarker = { - id: crypto.randomUUID(), - name, - longitude, - latitude, - color, - createdAt: new Date().toISOString(), + // Load markers from API on mount + useEffect(() => { + api.listMapMarkers().then((data) => { + if (data) { + setMarkers( + data.map((m) => ({ + id: m.id, + name: m.name, + longitude: m.longitude, + latitude: m.latitude, + color: m.color as PinColorId, + createdAt: m.created_at, + })) + ) + } + setLoaded(true) + }) + }, []) + + const addMarker = useCallback( + async (name: string, longitude: number, latitude: number, color: PinColorId = 'orange') => { + const result = await api.createMapMarker({ name, longitude, latitude, color }) + if (result) { + const marker: MapMarker = { + id: result.id, + name: result.name, + longitude: result.longitude, + latitude: result.latitude, + color: result.color as PinColorId, + createdAt: result.created_at, + } + setMarkers((prev) => [...prev, marker]) + return marker + } + return null + }, + [] + ) + + const updateMarker = useCallback(async (id: number, updates: { name?: string; color?: string }) => { + const result = await api.updateMapMarker(id, updates) + if (result) { + setMarkers((prev) => + prev.map((m) => + m.id === id + ? { ...m, name: result.name, color: result.color as PinColorId } + : m + ) + ) } - setMarkers((prev) => { - const next = [...prev, marker] - persist(next) - return next - }) - return marker }, []) - const updateMarker = useCallback((id: string, name: string) => { - setMarkers((prev) => { - const next = prev.map((m) => (m.id === id ? { ...m, name } : m)) - persist(next) - return next - }) + const deleteMarker = useCallback(async (id: number) => { + await api.deleteMapMarker(id) + setMarkers((prev) => prev.filter((m) => m.id !== id)) }, []) - const deleteMarker = useCallback((id: string) => { - setMarkers((prev) => { - const next = prev.filter((m) => m.id !== id) - persist(next) - return next - }) - }, []) - - return { markers, addMarker, updateMarker, deleteMarker } + return { markers, loaded, addMarker, updateMarker, deleteMarker } } diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index a37e40f..dc1c7ed 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -571,6 +571,39 @@ class API { })() } + async listMapMarkers() { + return catchInternal(async () => { + const response = await this.client.get< + Array<{ id: number; name: string; longitude: number; latitude: number; color: string; created_at: string }> + >('/maps/markers') + return response.data + })() + } + + async createMapMarker(data: { name: string; longitude: number; latitude: number; color?: string }) { + return catchInternal(async () => { + const response = await this.client.post< + { id: number; name: string; longitude: number; latitude: number; color: string; created_at: string } + >('/maps/markers', data) + return response.data + })() + } + + async updateMapMarker(id: number, data: { name?: string; color?: string }) { + return catchInternal(async () => { + const response = await this.client.patch< + { id: number; name: string; longitude: number; latitude: number; color: string } + >(`/maps/markers/${id}`, data) + return response.data + })() + } + + async deleteMapMarker(id: number) { + return catchInternal(async () => { + await this.client.delete(`/maps/markers/${id}`) + })() + } + async listRemoteZimFiles({ start = 0, count = 12, diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 06ec524..d201174 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -80,6 +80,10 @@ router router.post('/download-collection', [MapsController, 'downloadCollection']) router.get('/global-map-info', [MapsController, 'globalMapInfo']) router.post('/download-global-map', [MapsController, 'downloadGlobalMap']) + router.get('/markers', [MapsController, 'listMarkers']) + router.post('/markers', [MapsController, 'createMarker']) + router.patch('/markers/:id', [MapsController, 'updateMarker']) + router.delete('/markers/:id', [MapsController, 'deleteMarker']) router.delete('/:filename', [MapsController, 'delete']) }) .prefix('/api/maps')