mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-05 16:26:15 +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
dc94e7cdaf
commit
866a6d5625
|
|
@ -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' }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 [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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user