mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-07 17:26:16 +02:00
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:
parent
f9af28fd33
commit
e0a3523807
|
|
@ -1,4 +1,5 @@
|
||||||
import { MapService } from '#services/map_service'
|
import { MapService } from '#services/map_service'
|
||||||
|
import MapMarker from '#models/map_marker'
|
||||||
import {
|
import {
|
||||||
assertNotPrivateUrl,
|
assertNotPrivateUrl,
|
||||||
downloadCollectionValidator,
|
downloadCollectionValidator,
|
||||||
|
|
@ -8,6 +9,7 @@ import {
|
||||||
} from '#validators/common'
|
} from '#validators/common'
|
||||||
import { inject } from '@adonisjs/core'
|
import { inject } from '@adonisjs/core'
|
||||||
import type { HttpContext } from '@adonisjs/core/http'
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
|
import vine from '@vinejs/vine'
|
||||||
|
|
||||||
@inject()
|
@inject()
|
||||||
export default class MapsController {
|
export default class MapsController {
|
||||||
|
|
@ -123,4 +125,60 @@ export default class MapsController {
|
||||||
message: 'Map file deleted successfully',
|
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' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
admin/app/models/map_marker.ts
Normal file
43
admin/app/models/map_marker.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ export default function MapComponent() {
|
||||||
const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null)
|
const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null)
|
||||||
const [markerName, setMarkerName] = useState('')
|
const [markerName, setMarkerName] = useState('')
|
||||||
const [markerColor, setMarkerColor] = useState<PinColorId>('orange')
|
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
|
// Add the PMTiles protocol to maplibre-gl
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -54,7 +54,7 @@ export default function MapComponent() {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleDeleteMarker = useCallback(
|
const handleDeleteMarker = useCallback(
|
||||||
(id: string) => {
|
(id: number) => {
|
||||||
if (selectedMarkerId === id) setSelectedMarkerId(null)
|
if (selectedMarkerId === id) setSelectedMarkerId(null)
|
||||||
deleteMarker(id)
|
deleteMarker(id)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ import type { MapMarker } from '~/hooks/useMapMarkers'
|
||||||
|
|
||||||
interface MarkerPanelProps {
|
interface MarkerPanelProps {
|
||||||
markers: MapMarker[]
|
markers: MapMarker[]
|
||||||
onDelete: (id: string) => void
|
onDelete: (id: number) => void
|
||||||
onFlyTo: (longitude: number, latitude: number) => void
|
onFlyTo: (longitude: number, latitude: number) => void
|
||||||
onSelect: (id: string | null) => void
|
onSelect: (id: number | null) => void
|
||||||
selectedMarkerId: string | null
|
selectedMarkerId: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MarkerPanel({
|
export default function MarkerPanel({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
|
import api from '~/lib/api'
|
||||||
|
|
||||||
export const PIN_COLORS = [
|
export const PIN_COLORS = [
|
||||||
{ id: 'orange', label: 'Orange', hex: '#a84a12' },
|
{ id: 'orange', label: 'Orange', hex: '#a84a12' },
|
||||||
|
|
@ -12,7 +13,7 @@ export const PIN_COLORS = [
|
||||||
export type PinColorId = typeof PIN_COLORS[number]['id']
|
export type PinColorId = typeof PIN_COLORS[number]['id']
|
||||||
|
|
||||||
export interface MapMarker {
|
export interface MapMarker {
|
||||||
id: string
|
id: number
|
||||||
name: string
|
name: string
|
||||||
longitude: number
|
longitude: number
|
||||||
latitude: number
|
latitude: number
|
||||||
|
|
@ -20,60 +21,66 @@ export interface MapMarker {
|
||||||
createdAt: string
|
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() {
|
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 => {
|
// Load markers from API on mount
|
||||||
const marker: MapMarker = {
|
useEffect(() => {
|
||||||
id: crypto.randomUUID(),
|
api.listMapMarkers().then((data) => {
|
||||||
name,
|
if (data) {
|
||||||
longitude,
|
setMarkers(
|
||||||
latitude,
|
data.map((m) => ({
|
||||||
color,
|
id: m.id,
|
||||||
createdAt: new Date().toISOString(),
|
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) => {
|
const deleteMarker = useCallback(async (id: number) => {
|
||||||
setMarkers((prev) => {
|
await api.deleteMapMarker(id)
|
||||||
const next = prev.map((m) => (m.id === id ? { ...m, name } : m))
|
setMarkers((prev) => prev.filter((m) => m.id !== id))
|
||||||
persist(next)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const deleteMarker = useCallback((id: string) => {
|
return { markers, loaded, addMarker, updateMarker, deleteMarker }
|
||||||
setMarkers((prev) => {
|
|
||||||
const next = prev.filter((m) => m.id !== id)
|
|
||||||
persist(next)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return { markers, addMarker, updateMarker, deleteMarker }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
async listRemoteZimFiles({
|
||||||
start = 0,
|
start = 0,
|
||||||
count = 12,
|
count = 12,
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,10 @@ router
|
||||||
router.post('/download-collection', [MapsController, 'downloadCollection'])
|
router.post('/download-collection', [MapsController, 'downloadCollection'])
|
||||||
router.get('/global-map-info', [MapsController, 'globalMapInfo'])
|
router.get('/global-map-info', [MapsController, 'globalMapInfo'])
|
||||||
router.post('/download-global-map', [MapsController, 'downloadGlobalMap'])
|
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'])
|
router.delete('/:filename', [MapsController, 'delete'])
|
||||||
})
|
})
|
||||||
.prefix('/api/maps')
|
.prefix('/api/maps')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user