mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2025-12-12 23:36:00 +00:00
* simplified icon config * fixed linting * fixed linting * fix linting and searchControl * adjust useSelectPosition * adjust useSelectPosition * fixed presets * removed menuIcon artefacts * fix example * adjust icon size in addButton
329 lines
14 KiB
TypeScript
329 lines
14 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
/* eslint-disable @typescript-eslint/require-await */
|
|
/* eslint-disable @typescript-eslint/no-misused-promises */
|
|
/* eslint-disable @typescript-eslint/no-floating-promises */
|
|
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
/* eslint-disable @typescript-eslint/restrict-plus-operands */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
import FlagIcon from '@heroicons/react/24/outline/FlagIcon'
|
|
import MagnifyingGlassIcon from '@heroicons/react/24/outline/MagnifyingGlassIcon'
|
|
import axios from 'axios'
|
|
import { LatLng, LatLngBounds, marker } from 'leaflet'
|
|
import { useRef, useState } from 'react'
|
|
import SVG from 'react-inlinesvg'
|
|
import { useMap, useMapEvents } from 'react-leaflet'
|
|
import { useNavigate } from 'react-router-dom'
|
|
|
|
import { useAppState } from '#components/AppShell/hooks/useAppState'
|
|
import { useDebounce } from '#components/Map/hooks/useDebounce'
|
|
import { useAddFilterTag } from '#components/Map/hooks/useFilter'
|
|
import { useItems } from '#components/Map/hooks/useItems'
|
|
import { useLeafletRefs } from '#components/Map/hooks/useLeafletRefs'
|
|
import { useTags } from '#components/Map/hooks/useTags'
|
|
import useWindowDimensions from '#components/Map/hooks/useWindowDimension'
|
|
import { decodeTag } from '#utils/FormatTags'
|
|
import MarkerIconFactory from '#utils/MarkerIconFactory'
|
|
|
|
import { LocateControl } from './LocateControl'
|
|
import { SidebarControl } from './SidebarControl'
|
|
|
|
import type { Item } from '#types/Item'
|
|
|
|
export const SearchControl = () => {
|
|
const windowDimensions = useWindowDimensions()
|
|
const [popupOpen, setPopupOpen] = useState(false)
|
|
|
|
const [value, setValue] = useState('')
|
|
const [geoResults, setGeoResults] = useState<any[]>([])
|
|
const [tagsResults, setTagsResults] = useState<any[]>([])
|
|
const [itemsResults, setItemsResults] = useState<Item[]>([])
|
|
const [hideSuggestions, setHideSuggestions] = useState(true)
|
|
|
|
const map = useMap()
|
|
const tags = useTags()
|
|
const items = useItems()
|
|
const leafletRefs = useLeafletRefs()
|
|
const addFilterTag = useAddFilterTag()
|
|
const appState = useAppState()
|
|
|
|
useMapEvents({
|
|
popupopen: () => {
|
|
setPopupOpen(true)
|
|
},
|
|
popupclose: () => {
|
|
setPopupOpen(false)
|
|
},
|
|
})
|
|
|
|
const navigate = useNavigate()
|
|
|
|
useDebounce(
|
|
() => {
|
|
const searchGeo = async () => {
|
|
try {
|
|
const { data } = await axios.get(`https://photon.komoot.io/api/?q=${value}&limit=5`)
|
|
setGeoResults(data.features)
|
|
// eslint-disable-next-line no-catch-all/no-catch-all
|
|
} catch (error) {
|
|
// eslint-disable-next-line no-console
|
|
console.log(error)
|
|
}
|
|
}
|
|
searchGeo()
|
|
setItemsResults(
|
|
items.filter((item) => {
|
|
return (
|
|
value.length > 2 &&
|
|
((item.layer?.listed && item.name.toLowerCase().includes(value.toLowerCase())) ||
|
|
item.text?.toLowerCase().includes(value.toLowerCase()))
|
|
)
|
|
}),
|
|
)
|
|
let phrase = value
|
|
if (value.startsWith('#')) phrase = value.substring(1)
|
|
setTagsResults(tags.filter((tag) => tag.name.toLowerCase().includes(phrase.toLowerCase())))
|
|
},
|
|
500,
|
|
[value],
|
|
)
|
|
|
|
const hide = async () => {
|
|
setTimeout(() => {
|
|
setHideSuggestions(true)
|
|
}, 200)
|
|
}
|
|
|
|
const searchInput = useRef<HTMLInputElement>(null)
|
|
|
|
return (
|
|
<>
|
|
{!(windowDimensions.height < 500 && popupOpen && hideSuggestions) && (
|
|
<div className='tw:w-[calc(100vw-2rem)] tw:max-w-[22rem] '>
|
|
<div className='tw:flex tw:flex-row'>
|
|
{appState.embedded && <SidebarControl />}
|
|
<div className='tw:relative tw:shrink tw:max-w-69 tw:w-full'>
|
|
<input
|
|
type='text'
|
|
placeholder='search ...'
|
|
autoComplete='off'
|
|
value={value}
|
|
className='tw:input tw:input-bordered tw:h-12 tw:grow tw:shadow-xl tw:rounded-box tw:pr-12 tw:w-full'
|
|
ref={searchInput}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
onFocus={() => {
|
|
setHideSuggestions(false)
|
|
if (windowDimensions.width < 500) map.closePopup()
|
|
}}
|
|
onBlur={() => hide()}
|
|
/>
|
|
{value.length > 0 && (
|
|
<button
|
|
className='tw:btn tw:btn-sm tw:btn-circle tw:absolute tw:right-2 tw:top-2'
|
|
onClick={() => setValue('')}
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
<LocateControl />
|
|
</div>
|
|
{hideSuggestions ||
|
|
(Array.from(geoResults).length === 0 &&
|
|
itemsResults.length === 0 &&
|
|
tagsResults.length === 0 &&
|
|
!isGeoCoordinate(value)) ||
|
|
value.length === 0 ? (
|
|
''
|
|
) : (
|
|
<div className='tw:card tw:card-body tw:bg-base-100 tw:p-4 tw:mt-2 tw:shadow-xl tw:overflow-y-auto tw:max-h-[calc(100dvh-152px)] tw:absolute tw:z-3000 tw:w-83'>
|
|
{tagsResults.length > 0 && (
|
|
<div className='tw:flex tw:flex-wrap'>
|
|
{tagsResults.slice(0, 3).map((tag) => (
|
|
<div
|
|
key={tag.name}
|
|
className='tw:rounded-2xl tw:text-white tw:p-1 tw:px-4 tw:shadow-md tw:card tw:mr-2 tw:mb-2 tw:cursor-pointer'
|
|
style={{ backgroundColor: tag.color }}
|
|
onClick={() => {
|
|
addFilterTag(tag)
|
|
}}
|
|
>
|
|
<b>#{decodeTag(tag.name)}</b>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{itemsResults.length > 0 && tagsResults.length > 0 && (
|
|
<hr className='tw:opacity-50'></hr>
|
|
)}
|
|
{itemsResults.slice(0, 5).map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className='tw:cursor-pointer tw:hover:font-bold tw:flex tw:flex-row'
|
|
onClick={() => {
|
|
const marker = Object.entries(leafletRefs).find((r) => r[1].item === item)?.[1]
|
|
.marker
|
|
if (marker) {
|
|
navigate(`/${item.id}?${new URLSearchParams(window.location.search)}`)
|
|
} else {
|
|
navigate(
|
|
'item/' + item.id + '?' + new URLSearchParams(window.location.search),
|
|
)
|
|
}
|
|
}}
|
|
>
|
|
{item.layer?.markerIcon.image ? (
|
|
<div className='tw:w-7 tw:h-full tw:flex tw:justify-center tw:items-center'>
|
|
<SVG
|
|
src={appState.assetsApi.url + item.layer.markerIcon.image}
|
|
className='tw:text-current tw:mr-2 tw:mt-0'
|
|
style={{ width: `${(item.layer.markerIcon.size ?? 18) * 1.2}px` }}
|
|
preProcessor={(code: string): string => {
|
|
code = code.replace(/fill=".*?"/g, 'fill="currentColor"')
|
|
code = code.replace(/stroke=".*?"/g, 'stroke="currentColor"')
|
|
return code
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className='tw:w-7' />
|
|
)}
|
|
<div>
|
|
<div className='tw:text-sm tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:max-w-[17rem]'>
|
|
{item.name}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{Array.from(geoResults).length > 0 &&
|
|
(itemsResults.length > 0 || tagsResults.length > 0) && (
|
|
<hr className='tw:opacity-50'></hr>
|
|
)}
|
|
{Array.from(geoResults).map((geo) => (
|
|
<div
|
|
className='tw:flex tw:flex-row tw:hover:font-bold tw:cursor-pointer'
|
|
key={Math.random()}
|
|
onClick={() => {
|
|
searchInput.current?.blur()
|
|
marker(new LatLng(geo.geometry.coordinates[1], geo.geometry.coordinates[0]), {
|
|
icon: MarkerIconFactory('circle', '#777', 'RGBA(35, 31, 32, 0.2)'),
|
|
})
|
|
.addTo(map)
|
|
.bindPopup(
|
|
`<h3 class="tw:text-base tw:font-bold">${geo?.properties.name ? geo?.properties.name : value}<h3>${capitalizeFirstLetter(geo?.properties?.osm_value)}`,
|
|
)
|
|
.openPopup()
|
|
.addEventListener('popupclose', (e) => {
|
|
// eslint-disable-next-line no-console
|
|
console.log(e.target.remove())
|
|
})
|
|
if (geo.properties.extent)
|
|
map.fitBounds(
|
|
new LatLngBounds(
|
|
new LatLng(geo.properties.extent[1], geo.properties.extent[0]),
|
|
new LatLng(geo.properties.extent[3], geo.properties.extent[2]),
|
|
),
|
|
)
|
|
else
|
|
map.setView(
|
|
new LatLng(geo.geometry.coordinates[1], geo.geometry.coordinates[0]),
|
|
15,
|
|
{ duration: 1 },
|
|
)
|
|
hide()
|
|
}}
|
|
>
|
|
<MagnifyingGlassIcon className='tw:text-current tw:mr-2 tw:mt-0 tw:w-5' />
|
|
<div>
|
|
<div className='tw:text-sm tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:max-w-[17rem]'>
|
|
{geo?.properties.name ? geo?.properties.name : value}
|
|
</div>
|
|
<div className='tw:text-xs tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:max-w-[17rem]'>
|
|
{geo?.properties?.city && `${capitalizeFirstLetter(geo?.properties?.city)}, `}{' '}
|
|
{geo?.properties?.osm_value &&
|
|
geo?.properties?.osm_value !== 'yes' &&
|
|
geo?.properties?.osm_value !== 'primary' &&
|
|
geo?.properties?.osm_value !== 'path' &&
|
|
geo?.properties?.osm_value !== 'secondary' &&
|
|
geo?.properties?.osm_value !== 'residential' &&
|
|
geo?.properties?.osm_value !== 'unclassified' &&
|
|
`${capitalizeFirstLetter(geo?.properties?.osm_value)}, `}{' '}
|
|
{geo.properties.state && `${geo.properties.state}, `}{' '}
|
|
{geo.properties.country && geo.properties.country}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{isGeoCoordinate(value) && (
|
|
<div
|
|
className='tw:flex tw:flex-row tw:hover:font-bold tw:cursor-pointer'
|
|
onClick={() => {
|
|
marker(
|
|
new LatLng(extractCoordinates(value)![0], extractCoordinates(value)![1]),
|
|
{
|
|
icon: MarkerIconFactory('circle', '#777', 'RGBA(35, 31, 32, 0.2)'),
|
|
},
|
|
)
|
|
.addTo(map)
|
|
.bindPopup(
|
|
`<h3 class="tw:text-base tw:font-bold">${extractCoordinates(value)![0]}, ${extractCoordinates(value)![1]}</h3>`,
|
|
)
|
|
.openPopup()
|
|
.addEventListener('popupclose', (e) => {
|
|
// eslint-disable-next-line no-console
|
|
console.log(e.target.remove())
|
|
})
|
|
map.setView(
|
|
new LatLng(extractCoordinates(value)![0], extractCoordinates(value)![1]),
|
|
15,
|
|
{ duration: 1 },
|
|
)
|
|
}}
|
|
>
|
|
<FlagIcon className='tw:text-current tw:mr-2 tw:mt-0 tw:w-4' />
|
|
<div>
|
|
<div className='tw:text-sm tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:max-w-[17rem]'>
|
|
{value}
|
|
</div>
|
|
<div className='tw:text-xs tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:max-w-[17rem]'>
|
|
{'Coordiante'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function isGeoCoordinate(input) {
|
|
const geokoordinatenRegex =
|
|
// eslint-disable-next-line security/detect-unsafe-regex
|
|
/^[-+]?([1-8]?\d(\.\d+)?|90(\.0+)?),\s*[-+]?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$/
|
|
return geokoordinatenRegex.test(input)
|
|
}
|
|
|
|
function extractCoordinates(input): number[] | null {
|
|
const result = input.split(',')
|
|
if (result) {
|
|
const latitude = parseFloat(result[0])
|
|
const longitude = parseFloat(result[1])
|
|
if (!isNaN(latitude) && !isNaN(longitude)) {
|
|
return [latitude, longitude]
|
|
}
|
|
}
|
|
return null // Invalid input or error
|
|
}
|
|
|
|
function capitalizeFirstLetter(string) {
|
|
return string.charAt(0).toUpperCase() + string.slice(1)
|
|
}
|