1b5876524a
Major geoportal enhancements: - Basemap switcher (OSM/Satellite/Terrain) with ESRI + OpenTopoMap tiles - Search bar with debounced lookup (UATs by name, parcels by cadastral ref, owners by name) - Feature info panel showing enrichment data from ParcelSync (cadastru, proprietari, suprafata, folosinta) - Parcel selection mode with amber highlight + export (GeoJSON/DXF/GPKG via ogr2ogr) - Next.js /tiles rewrite proxying to Martin (fixes dev + avoids mixed content) - Fixed MapLibre web worker relative URL resolution (window.location.origin) API routes: /api/geoportal/search, /api/geoportal/feature, /api/geoportal/export Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
157 lines
5.2 KiB
TypeScript
157 lines
5.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import { Search, MapPin, LandPlot, Building2, X, Loader2 } from "lucide-react";
|
|
import { Input } from "@/shared/components/ui/input";
|
|
import { Button } from "@/shared/components/ui/button";
|
|
import { cn } from "@/shared/lib/utils";
|
|
import type { SearchResult } from "../types";
|
|
|
|
type SearchBarProps = {
|
|
onResultSelect: (result: SearchResult) => void;
|
|
className?: string;
|
|
};
|
|
|
|
export function SearchBar({ onResultSelect, className }: SearchBarProps) {
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [open, setOpen] = useState(false);
|
|
const [selectedIdx, setSelectedIdx] = useState(-1);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
|
|
// Debounced search
|
|
const doSearch = useCallback((q: string) => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
if (q.length < 2) {
|
|
setResults([]);
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
debounceRef.current = setTimeout(() => {
|
|
setLoading(true);
|
|
fetch(`/api/geoportal/search?q=${encodeURIComponent(q)}&limit=15`)
|
|
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
|
|
.then((data: { results: SearchResult[] }) => {
|
|
setResults(data.results);
|
|
setOpen(data.results.length > 0);
|
|
setSelectedIdx(-1);
|
|
})
|
|
.catch(() => {
|
|
setResults([]);
|
|
setOpen(false);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, 300);
|
|
}, []);
|
|
|
|
// Click outside to close
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handler);
|
|
return () => document.removeEventListener("mousedown", handler);
|
|
}, []);
|
|
|
|
const handleSelect = (result: SearchResult) => {
|
|
setOpen(false);
|
|
setQuery(result.label);
|
|
onResultSelect(result);
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (!open || results.length === 0) return;
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setSelectedIdx((i) => Math.min(i + 1, results.length - 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setSelectedIdx((i) => Math.max(i - 1, 0));
|
|
} else if (e.key === "Enter" && selectedIdx >= 0) {
|
|
e.preventDefault();
|
|
const sel = results[selectedIdx];
|
|
if (sel) handleSelect(sel);
|
|
} else if (e.key === "Escape") {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
|
|
const typeIcon = (type: SearchResult["type"]) => {
|
|
switch (type) {
|
|
case "uat":
|
|
return <MapPin className="h-3.5 w-3.5 text-violet-500" />;
|
|
case "parcel":
|
|
return <LandPlot className="h-3.5 w-3.5 text-green-500" />;
|
|
case "building":
|
|
return <Building2 className="h-3.5 w-3.5 text-blue-500" />;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className={cn("relative", className)}>
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Cauta parcela, UAT, proprietar..."
|
|
value={query}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value);
|
|
doSearch(e.target.value);
|
|
}}
|
|
onFocus={() => {
|
|
if (results.length > 0) setOpen(true);
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
className="pl-8 pr-8 h-8 text-sm bg-background/95 backdrop-blur-sm"
|
|
/>
|
|
{loading && (
|
|
<Loader2 className="absolute right-8 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
|
)}
|
|
{query && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute right-0.5 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
|
|
onClick={() => {
|
|
setQuery("");
|
|
setResults([]);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Results dropdown */}
|
|
{open && results.length > 0 && (
|
|
<div className="absolute top-full mt-1 w-full bg-background border rounded-lg shadow-lg overflow-hidden z-50 max-h-80 overflow-y-auto">
|
|
{results.map((r, i) => (
|
|
<button
|
|
key={r.id}
|
|
className={cn(
|
|
"w-full flex items-start gap-2.5 px-3 py-2 text-left hover:bg-muted/50 transition-colors",
|
|
i === selectedIdx && "bg-muted"
|
|
)}
|
|
onClick={() => handleSelect(r)}
|
|
onMouseEnter={() => setSelectedIdx(i)}
|
|
>
|
|
<div className="mt-0.5 shrink-0">{typeIcon(r.type)}</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium truncate">{r.label}</p>
|
|
{r.sublabel && (
|
|
<p className="text-xs text-muted-foreground truncate">{r.sublabel}</p>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|