diff --git a/admin/inertia/components/maps/IconSelectorPopover.tsx b/admin/inertia/components/maps/IconSelectorPopover.tsx index f0310910..ef7a6afa 100644 --- a/admin/inertia/components/maps/IconSelectorPopover.tsx +++ b/admin/inertia/components/maps/IconSelectorPopover.tsx @@ -1,11 +1,4 @@ -import { useMemo, useState } from 'react' -import * as TablerIcons from '@tabler/icons-react' -import type { IconProps } from '@tabler/icons-react' -import type { ComponentType } from 'react' -import * as FontAwesomeIcons from 'react-icons/fa' -import type { IconType } from 'react-icons' - -const PAGE_SIZE = 48 +import { MARKER_ICONS } from './marker_icons' type IconSelectorPopoverProps = { selectedIcon?: string | null @@ -13,144 +6,43 @@ type IconSelectorPopoverProps = { onClose: () => void } -const normalizeIconNameForSort = (name: string) => { - return name - .replace(/^Icon/, '') // Tabler: IconHome -> Home - .replace(/^Fa/, '') // FontAwesome: FaHome -> Home - .toLowerCase() -} - -type IconEntry = { - name: string - label: string - library: 'tabler' | 'fa' - Icon: ComponentType | IconType -} - -const tablerIconEntries: IconEntry[] = Object.entries(TablerIcons) - .filter(([name, value]) => { - return ( - name.startsWith('Icon') && - name !== 'Icon' && - value !== null && - (typeof value === 'function' || typeof value === 'object') - ) - }) - .map(([name, Icon]) => ({ - name: `tabler:${name}`, - label: name, - library: 'tabler' as const, - Icon: Icon as ComponentType, - })) - -const fontAwesomeIconEntries: IconEntry[] = Object.entries(FontAwesomeIcons) - .filter(([name, value]) => { - return ( - name.startsWith('Fa') && - value !== null && - (typeof value === 'function' || typeof value === 'object') - ) - }) - .map(([name, Icon]) => ({ - name: `fa:${name}`, - label: name, - library: 'fa' as const, - Icon: Icon as IconType, - })) - -const iconEntries: IconEntry[] = [ - ...tablerIconEntries, - ...fontAwesomeIconEntries, -].sort((a, b) => a.label.localeCompare(b.label)) - +/** + * The marker icon picker: the whole curated set, six across and six down. + * + * No search box and no paging, because there is nothing to search or page + * through -- 36 icons fit on screen at once, which is the point of curating + * them. See marker_icons.ts for why the set is a hand-written list. + */ 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((entry) => { - const normalizedLabel = normalizeIconNameForSort(entry.label) - - return ( - entry.label.toLowerCase().includes(normalizedQuery) || - normalizedLabel.includes(normalizedQuery) || - entry.library.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) - + selectedIcon, + onSelect, + onClose, +}: IconSelectorPopoverProps) { 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, label, library, Icon }) => ( - - ))} -
-
- -
- - - - {filteredIcons.length === 0 ? 'No icons' : `${page + 1} / ${pageCount}`} - - - +
+ {MARKER_ICONS.map(({ name, label, Icon }) => ( + + ))}
diff --git a/admin/inertia/components/maps/MarkerPanel.tsx b/admin/inertia/components/maps/MarkerPanel.tsx index edfa021b..0a0856f7 100644 --- a/admin/inertia/components/maps/MarkerPanel.tsx +++ b/admin/inertia/components/maps/MarkerPanel.tsx @@ -1,3 +1,4 @@ +import { resolveMarkerIcon } from './marker_icons' import { useMemo, useState } from 'react' import { IconEye, @@ -9,9 +10,6 @@ import { IconTrash, IconX, } from '@tabler/icons-react' -import * as FontAwesomeIcons from 'react-icons/fa' -import type { IconType } from 'react-icons' -import * as TablerIcons from '@tabler/icons-react' import type { IconProps } from '@tabler/icons-react' import type { ComponentType } from 'react' @@ -29,7 +27,6 @@ interface MarkerPanelProps { type SortField = 'name' | 'color' | 'visibility' | 'icon' type SortDirection = 'asc' | 'desc' -type ViewMode = 'list' | 'tree' type ColorSortValue = { bucket: number @@ -115,58 +112,6 @@ const getReadableIconName = (icon?: string | null) => { .replace(/^Fa/, '') .replace(/^Icon/, '') } -const resolveMarkerIcon = ( - icon?: string | null -): ComponentType | IconType => { - if (!icon) return IconMapPinFilled - - // Font Awesome - if (icon.startsWith('fa:')) { - const iconName = icon.replace('fa:', '') - const Icon = (FontAwesomeIcons as Record)[iconName] - return Icon ? (Icon as IconType) : IconMapPinFilled - } - - // Tabler - if (icon.startsWith('tabler:')) { - const iconName = icon.replace('tabler:', '') - const Icon = (TablerIcons as Record)[iconName] - return Icon ? (Icon as ComponentType) : IconMapPinFilled - } - - // Backward compatibility - const Icon = - (TablerIcons as Record)[icon] ?? - (FontAwesomeIcons as Record)[icon] - - return Icon ? (Icon as ComponentType | IconType) : IconMapPinFilled -} - -const getMarkerGroup = (marker: MapMarker, sortField: SortField) => { - if (sortField === 'name') { - const firstLetter = marker.name.trim().charAt(0).toUpperCase() - - if (!firstLetter || !/[A-Z]/.test(firstLetter)) { - return { key: '#', label: '#' } - } - - return { key: firstLetter, label: firstLetter } - } - - if (sortField === 'color') { - const label = getHueGroupLabel(getColorSortValue(marker.color, marker.customColor)) - return { key: label, label } - } - - if (sortField === 'icon') { - const label = getReadableIconName(marker.icon) - return { key: label, label } - } - - return marker.visible - ? { key: 'visible', label: 'Visible' } - : { key: 'hidden', label: 'Hidden' } -} export default function MarkerPanel({ markers, @@ -180,8 +125,6 @@ export default function MarkerPanel({ const [searchQuery, setSearchQuery] = useState('') const [sortField, setSortField] = useState('name') const [sortDirection, setSortDirection] = useState('asc') - const [viewMode, setViewMode] = useState('list') - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) const sortDirectionLabel = sortField === 'name' @@ -232,66 +175,10 @@ export default function MarkerPanel({ }) }, [markers, searchQuery, sortField, sortDirection]) - const markerGroups = useMemo(() => { - const groups = new Map() - - filteredAndSortedMarkers.forEach((marker) => { - const group = getMarkerGroup(marker, sortField) - - if (!groups.has(group.key)) { - groups.set(group.key, { - key: group.key, - label: group.label, - markers: [], - }) - } - - groups.get(group.key)?.markers.push(marker) - }) - - return Array.from(groups.values()).sort((a, b) => { - const result = a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) - return sortDirection === 'asc' ? result : -result - }) - }, [filteredAndSortedMarkers, sortField, sortDirection]) - - const allGroupsCollapsed = - markerGroups.length > 0 && markerGroups.every((group) => collapsedGroups.has(group.key)) - const allFilteredMarkersVisible = filteredAndSortedMarkers.length > 0 && filteredAndSortedMarkers.every((marker) => marker.visible) - const toggleGroupCollapsed = (groupKey: string) => { - setCollapsedGroups((prev) => { - const next = new Set(prev) - - if (next.has(groupKey)) { - next.delete(groupKey) - } else { - next.add(groupKey) - } - - return next - }) - } - - const collapseAllGroups = () => { - setCollapsedGroups(new Set(markerGroups.map((group) => group.key))) - } - - const expandAllGroups = () => { - setCollapsedGroups(new Set()) - } - - const setGroupVisibility = (group: MarkerGroup, visible: boolean) => { - group.markers.forEach((marker) => { - if (marker.visible !== visible) { - onToggleVisibility(marker.id, visible) - } - }) - } - const setAllMarkerVisibility = (visible: boolean) => { filteredAndSortedMarkers.forEach((marker) => { if (marker.visible !== visible) { @@ -377,7 +264,23 @@ export default function MarkerPanel({ return (
-
+ {/* The whole title bar closes the panel, mirroring the "Pins" button that + opens it -- clicking the title to open but having to find the X to + close is the kind of asymmetry that makes a panel feel fiddly. The X + stays as the visible affordance. */} +
setOpen(false)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setOpen(false) + } + }} + title="Close panel" + className="flex cursor-pointer items-center justify-between border-b border-border-subtle px-3 py-2.5 transition-colors hover:bg-surface-secondary" + >
@@ -390,14 +293,12 @@ export default function MarkerPanel({ )}
- +
@@ -409,56 +310,6 @@ export default function MarkerPanel({ className="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" /> -
- - - -
- - {viewMode === 'tree' && ( -
- - - -
- )} -