diff --git a/admin/app/controllers/maps_controller.ts b/admin/app/controllers/maps_controller.ts index 097d9fb..aaefbe1 100644 --- a/admin/app/controllers/maps_controller.ts +++ b/admin/app/controllers/maps_controller.ts @@ -164,33 +164,49 @@ export default class MapsController { longitude: vine.number().min(-180).max(180), latitude: vine.number().min(-90).max(90), color: vine.string().trim().maxLength(20).optional(), + custom_color: vine.string().trim().maxLength(7).nullable().optional(), + icon: vine.string().trim().maxLength(50).nullable().optional(), + icon_color: vine.string().trim().maxLength(7).nullable().optional(), + visible: vine.boolean().optional(), notes: vine.string().trim().nullable().optional(), marker_type: vine.string().trim().maxLength(20).optional(), }) ) ) + const marker = await MapMarker.create({ name: payload.name, longitude: payload.longitude, latitude: payload.latitude, color: payload.color ?? 'orange', + custom_color: payload.custom_color ?? null, + icon: payload.icon ?? null, + icon_color: payload.icon_color ?? null, + visible: payload.visible ?? true, notes: payload.notes ?? null, marker_type: payload.marker_type ?? 'pin', }) + 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(), + custom_color: vine.string().trim().maxLength(7).nullable().optional(), + icon: vine.string().trim().maxLength(50).nullable().optional(), + icon_color: vine.string().trim().maxLength(7).nullable().optional(), + visible: vine.boolean().optional(), longitude: vine.number().min(-180).max(180).optional(), latitude: vine.number().min(-90).max(90).optional(), notes: vine.string().trim().nullable().optional(), @@ -198,12 +214,18 @@ export default class MapsController { }) ) ) + if (payload.name !== undefined) marker.name = payload.name if (payload.color !== undefined) marker.color = payload.color + if (payload.custom_color !== undefined) marker.custom_color = payload.custom_color + if (payload.icon !== undefined) marker.icon = payload.icon + if (payload.icon_color !== undefined) marker.icon_color = payload.icon_color + if (payload.visible !== undefined) marker.visible = payload.visible if (payload.longitude !== undefined) marker.longitude = payload.longitude if (payload.latitude !== undefined) marker.latitude = payload.latitude if (payload.notes !== undefined) marker.notes = payload.notes if (payload.marker_type !== undefined) marker.marker_type = payload.marker_type + await marker.save() return marker } diff --git a/admin/app/models/map_marker.ts b/admin/app/models/map_marker.ts index 7d588fd..7a53ee9 100644 --- a/admin/app/models/map_marker.ts +++ b/admin/app/models/map_marker.ts @@ -35,6 +35,18 @@ export default class MapMarker extends BaseModel { @column() declare notes: string | null + @column() + declare custom_color: string | null + + @column() + declare icon: string | null + + @column() + declare icon_color: string | null + + @column() + declare visible: boolean + @column.dateTime({ autoCreate: true }) declare created_at: DateTime diff --git a/admin/database/migrations/1771200000001_create_map_markers_table.ts b/admin/database/migrations/1771200000001_create_map_markers_table.ts index 3268de4..ce228d3 100644 --- a/admin/database/migrations/1771200000001_create_map_markers_table.ts +++ b/admin/database/migrations/1771200000001_create_map_markers_table.ts @@ -9,7 +9,11 @@ export default class extends BaseSchema { table.string('name').notNullable() table.double('longitude').notNullable() table.double('latitude').notNullable() - table.string('color', 20).notNullable().defaultTo('orange') + table.string('color', 20).notNullable().defaultTo('orange') // retaining this for backward compatibility + table.string('custom_color', 7).nullable() // "#aabbcc" + table.string('icon', 50).nullable() // "circle", "star", "flag", etc. + table.string('icon_color', 7).nullable() + table.boolean('visible').notNullable().defaultTo(true) table.string('marker_type', 20).notNullable().defaultTo('pin') table.string('route_id').nullable() table.integer('route_order').nullable() diff --git a/admin/database/migrations/1771200000002_add_visibility_and_icon_fields_to_map_markers.ts b/admin/database/migrations/1771200000002_add_visibility_and_icon_fields_to_map_markers.ts new file mode 100644 index 0000000..3b95e08 --- /dev/null +++ b/admin/database/migrations/1771200000002_add_visibility_and_icon_fields_to_map_markers.ts @@ -0,0 +1,23 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class AddVisibilityAndIconFieldsToMapMarkers extends BaseSchema { + protected tableName = 'map_markers' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.string('custom_color', 7).nullable() + table.string('icon', 50).nullable() + table.string('icon_color', 7).nullable() + table.boolean('visible').notNullable().defaultTo(true) + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('custom_color') + table.dropColumn('icon') + table.dropColumn('icon_color') + table.dropColumn('visible') + }) + } +} diff --git a/admin/inertia/components/maps/IconSelectorPopover.tsx b/admin/inertia/components/maps/IconSelectorPopover.tsx new file mode 100644 index 0000000..9d47928 --- /dev/null +++ b/admin/inertia/components/maps/IconSelectorPopover.tsx @@ -0,0 +1,119 @@ +import { useMemo, useState } from 'react' +import * as TablerIcons from '@tabler/icons-react' +import type { IconProps } from '@tabler/icons-react' +import type { ComponentType } from 'react' + +const PAGE_SIZE = 48 + +type IconSelectorPopoverProps = { + selectedIcon?: string | null + onSelect: (iconName: string) => void + onClose: () => void +} + +const iconEntries = Object.entries(TablerIcons) + .filter(([name, value]) => { + return ( + name.startsWith('Icon') && + name !== 'Icon' && + value !== null && + (typeof value === 'function' || typeof value === 'object') + ) + }) + .sort(([a], [b]) => a.localeCompare(b)) as Array<[string, ComponentType]> + +export default function IconSelectorPopover({ + selectedIcon, + onSelect, + onClose, + }: IconSelectorPopoverProps) { + const [query, setQuery] = useState('') + const [page, setPage] = useState(0) + + const filteredIcons = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase() + + return iconEntries.filter(([name]) => name.toLowerCase().includes(normalizedQuery)) + }, [query]) + + const pageCount = Math.max(1, Math.ceil(filteredIcons.length / PAGE_SIZE)) + + const pagedIcons = filteredIcons.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE) + + return ( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + { + setQuery(e.target.value) + setPage(0) + }} + className="mb-2 block w-full rounded border border-border-default bg-surface-primary px-2 py-1 text-sm text-text-primary placeholder:text-text-muted focus:border-desert-green focus:outline-none" + /> + +
+
+ {pagedIcons.map(([name, Icon]) => ( + + ))} +
+
+ +
+ + + + {filteredIcons.length === 0 ? 'No icons' : `${page + 1} / ${pageCount}`} + + + +
+ +
+ +
+
+ ) +} diff --git a/admin/inertia/components/maps/MapComponent.tsx b/admin/inertia/components/maps/MapComponent.tsx index 898ba2b..fab4464 100644 --- a/admin/inertia/components/maps/MapComponent.tsx +++ b/admin/inertia/components/maps/MapComponent.tsx @@ -12,7 +12,6 @@ import 'maplibre-gl/dist/maplibre-gl.css' import { Protocol } from 'pmtiles' import { useCallback, useEffect, useRef, useState } from 'react' -import { useToast } from '~/hooks/useToast' import { useMapMarkers } from '~/hooks/useMapMarkers' import MarkerPin from './MarkerPin' @@ -21,7 +20,6 @@ import CoordinateOverlay from './CoordinateOverlay' import ViewMapMarkerPopup from './ViewMapMarkerPopup' import MapMarkerFormPopup from './MapMarkerFormPopup' import ScaleUnitToggle from './ScaleUnitToggle' -import ToastContainer from '~/components/ToastContainer' type ScaleUnit = 'imperial' | 'metric' @@ -100,7 +98,6 @@ export default function MapComponent({ const animationFrameRef = useRef(null) const handledMapCommandIdRef = useRef(null) - const { toasts, showToast } = useToast() const { markers, addMarker, updateMarker, deleteMarker } = useMapMarkers() const [targetIndicator, setTargetIndicator] = useState<{ lng: number; lat: number } | null>(null) @@ -127,26 +124,6 @@ export default function MapComponent({ setCursorLngLat(null) }, []) - const copyCoordinatesToClipboard = useCallback( - async (lat: number, lng: number) => { - const coordinates = `${lat.toFixed(6)},${lng.toFixed(6)}` - - try { - await navigator.clipboard.writeText(coordinates) - showToast(`Copied: ${coordinates}`) - } catch { - window.prompt('Copy coordinates:', coordinates) - showToast('Clipboard blocked — copy manually') - } - }, - [showToast] - ) - - const confirmDiscardMarkerChanges = useCallback(() => { - if (!hasUnsavedMarkerChanges) return true - return window.confirm('Discard unsaved marker changes?') - }, [hasUnsavedMarkerChanges]) - const flyToLocationParams = useCallback(() => { const location = getMapLocationParams() if (!location) return @@ -158,6 +135,11 @@ export default function MapComponent({ }) }, []) + const confirmDiscardMarkerChanges = useCallback(() => { + if (!hasUnsavedMarkerChanges) return true + return window.confirm('Discard unsaved marker changes?') + }, [hasUnsavedMarkerChanges]) + useEffect(() => { const protocol = new Protocol() maplibregl.addProtocol('pmtiles', protocol.tile) @@ -245,12 +227,7 @@ export default function MapComponent({ }, [flyToLocationParams]) const handleMapClick = useCallback( - async (e: MapLayerMouseEvent) => { - if (e.originalEvent.shiftKey) { - await copyCoordinatesToClipboard(e.lngLat.lat, e.lngLat.lng) - return - } - + (e: MapLayerMouseEvent) => { if (!confirmDiscardMarkerChanges()) return setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat }) @@ -259,7 +236,7 @@ export default function MapComponent({ setHasUnsavedMarkerChanges(false) setTargetIndicator(null) }, - [confirmDiscardMarkerChanges, copyCoordinatesToClipboard] + [confirmDiscardMarkerChanges] ) const handleMouseMove = useCallback( @@ -398,21 +375,16 @@ export default function MapComponent({ /> {markers - .filter((marker) => marker.visible && isValidMarkerCoordinate(marker)) + .filter((marker) => marker.visible) .map((marker) => ( { + onClick={(e) => { e.originalEvent.stopPropagation() - if (e.originalEvent.shiftKey) { - await copyCoordinatesToClipboard(marker.latitude, marker.longitude) - return - } - if (!confirmDiscardMarkerChanges()) return setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id) @@ -439,7 +411,7 @@ export default function MapComponent({ latitude={placingMarker.lat} onDirtyChange={setHasUnsavedMarkerChanges} onMouseEnter={hideCoordinates} - onSave={async ({ name, notes, color, customColor, icon }) => { + onSave={async ({ name, notes, color, customColor, icon }) => { await addMarker({ name, longitude: placingMarker.lng, @@ -481,7 +453,7 @@ export default function MapComponent({ initialMarker={selectedMarker} onDirtyChange={setHasUnsavedMarkerChanges} onMouseEnter={hideCoordinates} - onSave={async ({ id, name, notes, color, customColor, icon }) => { + onSave={async ({ id, name, notes, color, customColor, icon }) => { if (!id) return await updateMarker(id, { @@ -516,8 +488,6 @@ export default function MapComponent({ onToggleVisibility={(id, visible) => updateMarker(id, { visible })} /> - - ) } diff --git a/admin/inertia/components/maps/MapMarkerFormPopup.tsx b/admin/inertia/components/maps/MapMarkerFormPopup.tsx index a84278e..4d7ad25 100644 --- a/admin/inertia/components/maps/MapMarkerFormPopup.tsx +++ b/admin/inertia/components/maps/MapMarkerFormPopup.tsx @@ -1,177 +1,251 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { Popup } from 'react-map-gl/maplibre' +import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react' +import {Popup} from 'react-map-gl/maplibre' +import {IconPalette} from '@tabler/icons-react' -import { PIN_COLORS } from '~/hooks/useMapMarkers' -import type { MapMarker, PinColorId } from '~/hooks/useMapMarkers' +import {IconIcons} from '@tabler/icons-react' +import IconSelectorPopover from './IconSelectorPopover' + +import {PIN_COLORS} from '~/hooks/useMapMarkers' +import type {MapMarker, PinColorId} from '~/hooks/useMapMarkers' const MAX_MARKER_NOTES_LENGTH = 500 const inputClass = - 'block w-full rounded border border-gray-300 bg-transparent px-2 py-1 text-sm text-gray-900 leading-normal placeholder:text-gray-400 focus:outline-none focus:border-gray-500' + 'block w-full rounded border border-gray-300 bg-transparent px-2 py-1 text-sm text-gray-900 leading-normal placeholder:text-gray-400 focus:outline-none focus:border-gray-500' type MapMarkerFormPopupProps = { - longitude: number - latitude: number - initialMarker?: MapMarker - onSave: (values: { - id?: number - name: string - notes: string - color: PinColorId - }) => Promise | void - onCancel: () => void - onDirtyChange?: (dirty: boolean) => void - onMouseEnter?: () => void + longitude: number + latitude: number + initialMarker?: MapMarker + onSave: (values: { + id?: number + name: string + notes: string + color: PinColorId + customColor: string | null + icon: string | null + }) => Promise | void + onCancel: () => void + onDirtyChange?: (dirty: boolean) => void + onMouseEnter?: () => void } export default function MapMarkerFormPopup({ - longitude, - latitude, - initialMarker, - onSave, - onCancel, - onDirtyChange, - onMouseEnter, + longitude, + latitude, + initialMarker, + onSave, + onCancel, + onDirtyChange, + onMouseEnter, }: MapMarkerFormPopupProps) { - const [name, setName] = useState(initialMarker?.name ?? '') - const [notes, setNotes] = useState(initialMarker?.notes ?? '') - const [color, setColor] = useState(initialMarker?.color ?? 'orange') - const [isSaving, setIsSaving] = useState(false) + const [name, setName] = useState(initialMarker?.name ?? '') + const [notes, setNotes] = useState(initialMarker?.notes ?? '') + const [color, setColor] = useState(initialMarker?.color ?? 'orange') + const [customColor, setCustomColor] = useState(initialMarker?.customColor ?? null) + const [isSaving, setIsSaving] = useState(false) - const textareaRef = useRef(null) + const textareaRef = useRef(null) + const nameInputRef = useRef(null) + const colorInputRef = useRef(null) - const resizeTextarea = useCallback(() => { - const textarea = textareaRef.current - if (!textarea) return + const resizeTextarea = useCallback(() => { + const textarea = textareaRef.current + if (!textarea) return - textarea.style.height = 'auto' - textarea.style.height = `${textarea.scrollHeight}px` - }, []) + textarea.style.height = 'auto' + textarea.style.height = `${textarea.scrollHeight}px` + }, []) - useLayoutEffect(() => { - resizeTextarea() - }, [resizeTextarea]) + const [icon, setIcon] = useState(initialMarker?.icon ?? null) + const [showIconSelector, setShowIconSelector] = useState(false) - const nameInputRef = useRef(null) + useLayoutEffect(() => { + resizeTextarea() + }, [resizeTextarea]) - useLayoutEffect(() => { - nameInputRef.current?.focus() - nameInputRef.current?.select() - }, []) + useLayoutEffect(() => { + nameInputRef.current?.focus() + nameInputRef.current?.select() + }, []) - const isDirty = - name !== (initialMarker?.name ?? '') || - notes !== (initialMarker?.notes ?? '') || - color !== (initialMarker?.color ?? 'orange') + const isDirty = + name !== (initialMarker?.name ?? '') || + notes !== (initialMarker?.notes ?? '') || + color !== (initialMarker?.color ?? 'orange') || + customColor !== (initialMarker?.customColor ?? null) || + icon !== (initialMarker?.icon ?? null) - useEffect(() => { - onDirtyChange?.(isDirty) - }, [isDirty, onDirtyChange]) + useEffect(() => { + onDirtyChange?.(isDirty) + }, [isDirty, onDirtyChange]) - const handleSave = async () => { - if (!name.trim() || isSaving) return + const handleSave = async () => { + if (!name.trim() || isSaving) return - try { - setIsSaving(true) + try { + setIsSaving(true) - await onSave({ - id: initialMarker?.id, - name: name.trim(), - notes: notes.trim(), - color, - }) - } finally { - setIsSaving(false) + await onSave({ + id: initialMarker?.id, + name: name.trim(), + notes: notes.trim(), + color, + customColor, + icon, + }) + } finally { + setIsSaving(false) + } } - } - return ( - -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - onMouseEnter={onMouseEnter} - > - setName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleSave() - if (e.key === 'Escape') onCancel() - }} - className={inputClass} - /> - -