feat(maps): persist markers to database instead of localStorage

Moves map marker storage from browser localStorage to a server-side database
table so pins survive cache clears, browser changes, and device switches.

Backend:
- New `map_markers` table with future-proofed columns for routing (marker_type,
  route_id, route_order, notes) to avoid a migration when routes are added later
- CRUD endpoints: GET/POST /api/maps/markers, PATCH/DELETE /api/maps/markers/:id
- VineJS validation on create/update
- MapMarker Lucid model

Frontend:
- useMapMarkers hook now fetches from API instead of localStorage
- Marker IDs changed from string (UUID) to number (DB auto-increment)
- API client methods added for all marker operations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-04-03 11:16:07 -07:00
parent f9af28fd33
commit e0a3523807
8 changed files with 226 additions and 56 deletions

View File

@ -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' }
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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<PinColorId>('orange')
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(null)
const [selectedMarkerId, setSelectedMarkerId] = useState<number | null>(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)
},

View File

@ -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({

View File

@ -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<MapMarker[]>(getInitialMarkers)
const [markers, setMarkers] = useState<MapMarker[]>([])
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 }
}

View File

@ -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,

View File

@ -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')