mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2025-12-13 07:46:10 +00:00
* support for svg files Support to load svg files and include them as bas64 encoded images in the bundle. * navbar svgs * replace NavBar SVGs with heroicons * layercontrol icons * lint fix * quest - questionmark * plusbutton - plus * linkeditem - elipse-vertical - link-slash * contactinfo - envelope & phone * avatar - arrow-up-tray * ActionButton - link & plus * StartEndView - calendar x2 * HeaderView - ellipse-vertical & pencil & trash * SidebarControl - bars-3 * SearchControl - flag & magnifying-glass * GratitudeControl - heart * FilterControl - funnel * AddButton - plus * reduce test coverage requirements * remove wrongfully commit dummy svg * updated obsolete package.lock * migrate more svgs from code to file, use hero icons where it seems applicable * moved share icons to subfolder * fixed layout --------- Co-authored-by: Anton Tranelis <mail@antontranelis.de>
327 lines
13 KiB
TypeScript
327 lines
13 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 { useEffect, useRef, useState } from 'react'
|
|
import { useMap, useMapEvents } from 'react-leaflet'
|
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
|
|
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()
|
|
|
|
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)
|
|
const [embedded, setEmbedded] = useState<boolean>(true)
|
|
|
|
const location = useLocation()
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(location.search)
|
|
const embedded = params.get('embedded')
|
|
embedded !== 'true' && setEmbedded(false)
|
|
}, [location])
|
|
|
|
return (
|
|
<>
|
|
{!(windowDimensions.height < 500 && popupOpen && hideSuggestions) && (
|
|
<div className='tw-w-[calc(100vw-2rem)] tw-max-w-[22rem] '>
|
|
<div className='tw-flex tw-flex-row'>
|
|
{embedded && <SidebarControl />}
|
|
<div className='tw-relative'>
|
|
<input
|
|
type='text'
|
|
placeholder='search ...'
|
|
autoComplete='off'
|
|
value={value}
|
|
className='tw-input tw-input-bordered tw-grow tw-shadow-xl tw-rounded-lg tw-pr-12'
|
|
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'>
|
|
{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 hover:tw-font-bold'
|
|
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),
|
|
)
|
|
}
|
|
}}
|
|
>
|
|
<div className='tw-flex tw-flex-row'>
|
|
<img
|
|
src={item.layer?.menuIcon}
|
|
className='tw-text-current tw-w-5 tw-mr-2 tw-mt-0'
|
|
/>
|
|
<div>
|
|
<div className='tw-text-sm tw-overflow-hidden tw-text-ellipsis tw-whitespace-nowrap tw-max-w-[17rem]'>
|
|
{item.name}
|
|
</div>
|
|
<div className='tw-text-xs tw-overflow-hidden tw-text-ellipsis tw-whitespace-nowrap tw-max-w-[17rem]'>
|
|
{item.text}
|
|
</div>
|
|
</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 hover:tw-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)', 'point'),
|
|
})
|
|
.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-4' />
|
|
<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 hover:tw-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)', 'point'),
|
|
},
|
|
)
|
|
.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)
|
|
}
|