diff --git a/admin/app/controllers/maps_controller.ts b/admin/app/controllers/maps_controller.ts index dd93a8b..7503989 100644 --- a/admin/app/controllers/maps_controller.ts +++ b/admin/app/controllers/maps_controller.ts @@ -140,33 +140,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(), @@ -174,12 +190,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/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 922713d..0a6c598 100644 --- a/admin/inertia/components/maps/MapComponent.tsx +++ b/admin/inertia/components/maps/MapComponent.tsx @@ -12,7 +12,7 @@ import 'maplibre-gl/dist/maplibre-gl.css' import { Protocol } from 'pmtiles' import { useCallback, useEffect, useRef, useState } from 'react' -import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers' +import { useMapMarkers } from '~/hooks/useMapMarkers' import MarkerPin from './MarkerPin' import MarkerPanel from './MarkerPanel' @@ -198,6 +198,17 @@ export default function MapComponent({ } }, [mapCommand, confirmDiscardMarkerChanges]) + useEffect(() => { + if (!selectedMarkerId) return + + const marker = markers.find((m) => m.id === selectedMarkerId) + + if (!marker || marker.visible === false) { + setSelectedMarkerId(null) + setEditingMarkerId(null) + } + }, [markers, selectedMarkerId]) + const handleScaleUnitChange = useCallback((unit: ScaleUnit) => { setScaleUnit(unit) localStorage.setItem('nomad:map-scale-unit', unit) @@ -355,38 +366,53 @@ export default function MapComponent({ onMouseEnter={hideCoordinates} /> - {markers.map((marker) => ( - { - e.originalEvent.stopPropagation() + {markers + .filter((marker) => marker.visible) + .map((marker) => ( + { + e.originalEvent.stopPropagation() - if (!confirmDiscardMarkerChanges()) return + if (!confirmDiscardMarkerChanges()) return - setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id) - setPlacingMarker(null) - setEditingMarkerId(null) - setHasUnsavedMarkerChanges(false) - setTargetIndicator(null) - }} - > - color.id === marker.color)?.hex} - active={marker.id === selectedMarkerId} - /> - - ))} + setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id) + setPlacingMarker(null) + setEditingMarkerId(null) + setHasUnsavedMarkerChanges(false) + setTargetIndicator(null) + }} + > + + + ))} {placingMarker && ( { - await addMarker(name, placingMarker.lng, placingMarker.lat, color, notes || undefined) + onSave={async ({ name, notes, color, customColor, icon }) => { + await addMarker({ + name, + longitude: placingMarker.lng, + latitude: placingMarker.lat, + color, + customColor, + icon, + notes: notes || null, + }) + setPlacingMarker(null) setHasUnsavedMarkerChanges(false) setTargetIndicator(null) @@ -418,13 +444,15 @@ export default function MapComponent({ initialMarker={selectedMarker} onDirtyChange={setHasUnsavedMarkerChanges} onMouseEnter={hideCoordinates} - onSave={async ({ id, name, notes, color }) => { + onSave={async ({ id, name, notes, color, customColor, icon }) => { if (!id) return await updateMarker(id, { name, notes: notes || null, color, + customColor, + icon, }) setEditingMarkerId(null) @@ -448,6 +476,7 @@ export default function MapComponent({ onFlyTo={handleFlyTo} onSelect={setSelectedMarkerId} selectedMarkerId={selectedMarkerId} + 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} - /> - -