|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { Button } from "@/components/ui/button"; |
| 4 | +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; |
| 5 | +import { SearchResultItem, SearchResultList } from "@/components/ui/search-location"; |
| 6 | +import { useOpenStreetMapsLocations } from "@/hooks/openstreetmaps"; |
| 7 | +import { PopoverClose } from "@radix-ui/react-popover"; |
| 8 | +import { ChevronRightIcon, MapIcon, MapPinIcon, SearchIcon, XIcon } from "lucide-react"; |
| 9 | +import { useCallback, useMemo, useState } from "react"; |
| 10 | +import { LngLatBoundsLike, useMap } from "react-map-gl"; |
| 11 | +import { useDebouncedValue } from "rooks"; |
| 12 | + |
| 13 | +type LocationOption = { |
| 14 | + value: number | undefined; |
| 15 | + label: string; |
| 16 | + bbox: LngLatBoundsLike; |
| 17 | +}; |
| 18 | + |
| 19 | +type StoryOption = Omit<LocationOption, "bbox">; |
| 20 | + |
| 21 | +const SearchLocation = () => { |
| 22 | + const [open, setOpen] = useState(true); |
| 23 | + const [locationSearch, setLocationSearch] = useState(""); |
| 24 | + |
| 25 | + const { current: map } = useMap(); |
| 26 | + |
| 27 | + const [debouncedSearch] = useDebouncedValue(locationSearch, 500); |
| 28 | + |
| 29 | + const { data: locationData = [] } = useOpenStreetMapsLocations( |
| 30 | + { |
| 31 | + q: debouncedSearch, |
| 32 | + format: "json", |
| 33 | + limit: 5, |
| 34 | + }, |
| 35 | + { |
| 36 | + enabled: debouncedSearch.length >= 1, |
| 37 | + placeholderData: (prev) => prev, |
| 38 | + }, |
| 39 | + ); |
| 40 | + |
| 41 | + const locationOptions = useMemo(() => { |
| 42 | + if (!Array.isArray(locationData) || debouncedSearch.length < 1) return []; |
| 43 | + return locationData?.reduce<LocationOption[]>((prev, curr) => { |
| 44 | + if (!curr.boundingbox) return prev; |
| 45 | + // nominatim boundingbox: [min latitude, max latitude, min longitude, max longitude] |
| 46 | + const [minLat, maxLat, minLng, maxLng] = curr.boundingbox; |
| 47 | + |
| 48 | + // mapbox bounds |
| 49 | + // [[lng, lat] - southwestern corner of the bounds |
| 50 | + // [lng, lat]] - northeastern corner of the bounds |
| 51 | + const mapboxBounds = [ |
| 52 | + [Number(minLng), Number(minLat)], |
| 53 | + [Number(maxLng), Number(maxLat)], |
| 54 | + ]; |
| 55 | + |
| 56 | + return [ |
| 57 | + ...prev, |
| 58 | + { |
| 59 | + value: curr.place_id ?? undefined, |
| 60 | + label: curr.display_name ?? "", |
| 61 | + bbox: mapboxBounds as LngLatBoundsLike, |
| 62 | + }, |
| 63 | + ]; |
| 64 | + }, []); |
| 65 | + }, [locationData, debouncedSearch]); |
| 66 | + |
| 67 | + // TODO: add real stories |
| 68 | + const storiesOptions: StoryOption[] = []; |
| 69 | + |
| 70 | + const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { |
| 71 | + setLocationSearch(e.target.value); |
| 72 | + }, []); |
| 73 | + |
| 74 | + const handleOptionClick = useCallback( |
| 75 | + (option: LocationOption) => { |
| 76 | + if (map) { |
| 77 | + map.fitBounds(option.bbox, { |
| 78 | + duration: 1000, |
| 79 | + padding: { top: 50, bottom: 50, left: 350, right: 50 }, |
| 80 | + }); |
| 81 | + |
| 82 | + setLocationSearch(""); |
| 83 | + setOpen(false); |
| 84 | + } |
| 85 | + }, |
| 86 | + [map], |
| 87 | + ); |
| 88 | + |
| 89 | + const handleStoryOptionClick = useCallback((option: StoryOption) => { |
| 90 | + // TODO: handle story option click |
| 91 | + }, []); |
| 92 | + |
| 93 | + const handleOpenChange = useCallback((open: boolean) => { |
| 94 | + setLocationSearch(""); |
| 95 | + setOpen(open); |
| 96 | + }, []); |
| 97 | + |
| 98 | + return ( |
| 99 | + <div> |
| 100 | + <Popover open={open} onOpenChange={handleOpenChange}> |
| 101 | + <PopoverTrigger asChild> |
| 102 | + <Button |
| 103 | + onClick={() => setOpen(!open)} |
| 104 | + variant="ghost" |
| 105 | + className="transition-color block h-min rounded-full border-2 border-background bg-background px-2 py-2 shadow-black/10 drop-shadow-md duration-300 hover:bg-orange-100 focus-visible:bg-global data-[state=open]:bg-global" |
| 106 | + > |
| 107 | + <SearchIcon className="h-5 w-5 stroke-foreground stroke-[1.5px]" /> |
| 108 | + </Button> |
| 109 | + </PopoverTrigger> |
| 110 | + <PopoverContent |
| 111 | + align="start" |
| 112 | + alignOffset={20} |
| 113 | + sideOffset={20} |
| 114 | + side="left" |
| 115 | + className="relative z-50 w-[348px] -translate-y-10 overflow-hidden rounded-lg bg-background px-0 py-0 shadow-lg drop-shadow-2xl" |
| 116 | + > |
| 117 | + <div> |
| 118 | + <div className="relative flex items-center justify-between p-1"> |
| 119 | + <SearchIcon className="absolute left-3 h-5 w-5 stroke-slate-300 stroke-[1.5px]" /> |
| 120 | + <input |
| 121 | + onChange={handleSearchChange} |
| 122 | + type="text" |
| 123 | + value={locationSearch} |
| 124 | + placeholder="Search" |
| 125 | + className="placeholder:text-popover-foreground/50 w-full border-2 border-background bg-background p-2 px-9 text-sm leading-none text-foreground placeholder:text-sm placeholder:font-light focus-visible:outline-global" |
| 126 | + /> |
| 127 | + {locationSearch.length >= 1 && ( |
| 128 | + <Button |
| 129 | + variant="ghost" |
| 130 | + size="sm" |
| 131 | + onClick={() => setLocationSearch("")} |
| 132 | + className="absolute right-9 h-fit w-fit rounded-full p-0.5 hover:bg-orange-100 focus-visible:ring-global data-[state=open]:bg-global" |
| 133 | + > |
| 134 | + <XIcon className="h-4 w-4 stroke-slate-400 stroke-[1.5px]" /> |
| 135 | + </Button> |
| 136 | + )} |
| 137 | + <PopoverClose className="mr-1 h-fit w-fit rounded-full p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-global"> |
| 138 | + <ChevronRightIcon className="mx-auto h-5 w-5" /> |
| 139 | + </PopoverClose> |
| 140 | + </div> |
| 141 | + |
| 142 | + {!!locationOptions.length && ( |
| 143 | + <SearchResultList title="Locations"> |
| 144 | + {locationOptions.map((option) => ( |
| 145 | + <SearchResultItem |
| 146 | + key={option.value} |
| 147 | + option={option} |
| 148 | + onOptionClick={handleOptionClick} |
| 149 | + > |
| 150 | + <MapPinIcon className="mt-0.5 h-4 w-4 shrink-0" /> |
| 151 | + </SearchResultItem> |
| 152 | + ))} |
| 153 | + </SearchResultList> |
| 154 | + )} |
| 155 | + |
| 156 | + {!!storiesOptions?.length && ( |
| 157 | + <SearchResultList title="Rangelands stories"> |
| 158 | + {storiesOptions.map((option) => ( |
| 159 | + <SearchResultItem |
| 160 | + key={option.value} |
| 161 | + option={option} |
| 162 | + onOptionClick={handleStoryOptionClick} |
| 163 | + > |
| 164 | + <MapIcon className="mt-0.5 h-4 w-4 shrink-0" /> |
| 165 | + </SearchResultItem> |
| 166 | + ))} |
| 167 | + </SearchResultList> |
| 168 | + )} |
| 169 | + </div> |
| 170 | + </PopoverContent> |
| 171 | + </Popover> |
| 172 | + </div> |
| 173 | + ); |
| 174 | +}; |
| 175 | + |
| 176 | +export default SearchLocation; |
0 commit comments