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 5cc2d3b..5b53608 100644 --- a/admin/inertia/components/maps/MapComponent.tsx +++ b/admin/inertia/components/maps/MapComponent.tsx @@ -1,10 +1,28 @@ -import Map, { FullscreenControl, NavigationControl, MapProvider } from 'react-map-gl/maplibre' +import Map, { + FullscreenControl, + NavigationControl, + ScaleControl, + Marker, + Popup, + MapProvider, +} from 'react-map-gl/maplibre' +import type { MapRef, MapLayerMouseEvent } from 'react-map-gl/maplibre' import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { Protocol } from 'pmtiles' -import { useEffect } from 'react' +import { useEffect, useRef, useState, useCallback } from 'react' +import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers' +import type { PinColorId } from '~/hooks/useMapMarkers' +import MarkerPin from './MarkerPin' +import MarkerPanel from './MarkerPanel' export default function MapComponent() { + const mapRef = useRef(null) + const { markers, addMarker, deleteMarker } = useMapMarkers() + const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null) + const [markerName, setMarkerName] = useState('') + const [markerColor, setMarkerColor] = useState('orange') + const [selectedMarkerId, setSelectedMarkerId] = useState(null) // Add the PMTiles protocol to maplibre-gl useEffect(() => { @@ -15,9 +33,40 @@ export default function MapComponent() { } }, []) + const handleMapClick = useCallback((e: MapLayerMouseEvent) => { + setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat }) + setMarkerName('') + setMarkerColor('orange') + setSelectedMarkerId(null) + }, []) + + const handleSaveMarker = useCallback(() => { + if (placingMarker && markerName.trim()) { + addMarker(markerName.trim(), placingMarker.lng, placingMarker.lat, markerColor) + setPlacingMarker(null) + setMarkerName('') + setMarkerColor('orange') + } + }, [placingMarker, markerName, markerColor, addMarker]) + + const handleFlyTo = useCallback((longitude: number, latitude: number) => { + mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 }) + }, []) + + const handleDeleteMarker = useCallback( + (id: number) => { + if (selectedMarkerId === id) setSelectedMarkerId(null) + deleteMarker(id) + }, + [selectedMarkerId, deleteMarker] + ) + + const selectedMarker = selectedMarkerId ? markers.find((m) => m.id === selectedMarkerId) : null + return ( + + + {/* Existing markers */} + {markers.map((marker) => ( + { + e.originalEvent.stopPropagation() + setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id) + setPlacingMarker(null) + }} + > + c.id === marker.color)?.hex} + active={marker.id === selectedMarkerId} + /> + + ))} + + {/* Popup for selected marker */} + {selectedMarker && ( + setSelectedMarkerId(null)} + closeOnClick={false} + > +
{selectedMarker.name}
+
+ )} + + {/* Popup for placing a new marker */} + {placingMarker && ( + setPlacingMarker(null)} + closeOnClick={false} + > +
+ setMarkerName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveMarker() + if (e.key === 'Escape') setPlacingMarker(null) + }} + className="block w-full rounded border border-gray-300 px-2 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:border-gray-500" + /> +
+ {PIN_COLORS.map((c) => ( + + ))} +
+
+ + +
+
+
+ )}
+ + {/* Marker panel overlay */} +
) } diff --git a/admin/inertia/components/maps/MarkerPanel.tsx b/admin/inertia/components/maps/MarkerPanel.tsx new file mode 100644 index 0000000..1bf448e --- /dev/null +++ b/admin/inertia/components/maps/MarkerPanel.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react' +import { IconMapPinFilled, IconTrash, IconMapPin, IconX } from '@tabler/icons-react' +import { PIN_COLORS } from '~/hooks/useMapMarkers' +import type { MapMarker } from '~/hooks/useMapMarkers' + +interface MarkerPanelProps { + markers: MapMarker[] + onDelete: (id: number) => void + onFlyTo: (longitude: number, latitude: number) => void + onSelect: (id: number | null) => void + selectedMarkerId: number | null +} + +export default function MarkerPanel({ + markers, + onDelete, + onFlyTo, + onSelect, + selectedMarkerId, +}: MarkerPanelProps) { + const [open, setOpen] = useState(false) + + if (!open) { + return ( + + ) + } + + return ( +
+ {/* Header */} +
+
+ + + Saved Locations + + {markers.length > 0 && ( + + {markers.length} + + )} +
+ +
+ + {/* Marker list */} +
+ {markers.length === 0 ? ( +
+ +

+ Click anywhere on the map to drop a pin +

+
+ ) : ( +
    + {markers.map((marker) => ( +
  • + c.id === marker.color)?.hex ?? '#a84a12' }} + /> + + +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/admin/inertia/components/maps/MarkerPin.tsx b/admin/inertia/components/maps/MarkerPin.tsx new file mode 100644 index 0000000..94c86af --- /dev/null +++ b/admin/inertia/components/maps/MarkerPin.tsx @@ -0,0 +1,17 @@ +import { IconMapPinFilled } from '@tabler/icons-react' + +interface MarkerPinProps { + color?: string + active?: boolean +} + +export default function MarkerPin({ color = '#a84a12', active = false }: MarkerPinProps) { + return ( +
+ +
+ ) +} diff --git a/admin/inertia/css/app.css b/admin/inertia/css/app.css index 74af512..9a7781d 100644 --- a/admin/inertia/css/app.css +++ b/admin/inertia/css/app.css @@ -118,4 +118,41 @@ body { --color-btn-green-active: #3a3c24; color-scheme: dark; +} + +/* MapLibre popup styling for dark mode */ +[data-theme="dark"] .maplibregl-popup-content { + background: #2a2918; + color: #f7eedc; +} + +[data-theme="dark"] .maplibregl-popup-content input { + background: #353420; + color: #f7eedc; + border-color: #424420; +} + +[data-theme="dark"] .maplibregl-popup-content input::placeholder { + color: #8f8f82; +} + +[data-theme="dark"] .maplibregl-popup-tip { + border-top-color: #2a2918; +} + +[data-theme="dark"] .maplibregl-popup-anchor-bottom .maplibregl-popup-tip { + border-top-color: #2a2918; +} + +[data-theme="dark"] .maplibregl-popup-anchor-top .maplibregl-popup-tip { + border-bottom-color: #2a2918; +} + +[data-theme="dark"] .maplibregl-popup-close-button { + color: #afafa5; +} + +[data-theme="dark"] .maplibregl-popup-close-button:hover { + color: #f7eedc; + background: #353420; } \ No newline at end of file diff --git a/admin/inertia/hooks/useMapMarkers.ts b/admin/inertia/hooks/useMapMarkers.ts new file mode 100644 index 0000000..ba999eb --- /dev/null +++ b/admin/inertia/hooks/useMapMarkers.ts @@ -0,0 +1,86 @@ +import { useState, useCallback, useEffect } from 'react' +import api from '~/lib/api' + +export const PIN_COLORS = [ + { id: 'orange', label: 'Orange', hex: '#a84a12' }, + { id: 'red', label: 'Red', hex: '#994444' }, + { id: 'green', label: 'Green', hex: '#424420' }, + { id: 'blue', label: 'Blue', hex: '#2563eb' }, + { id: 'purple', label: 'Purple', hex: '#7c3aed' }, + { id: 'yellow', label: 'Yellow', hex: '#ca8a04' }, +] as const + +export type PinColorId = typeof PIN_COLORS[number]['id'] + +export interface MapMarker { + id: number + name: string + longitude: number + latitude: number + color: PinColorId + createdAt: string +} + +export function useMapMarkers() { + const [markers, setMarkers] = useState([]) + const [loaded, setLoaded] = useState(false) + + // 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 + ) + ) + } + }, []) + + const deleteMarker = useCallback(async (id: number) => { + await api.deleteMapMarker(id) + setMarkers((prev) => prev.filter((m) => m.id !== id)) + }, []) + + 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')