diff --git a/src/components/Search/SmartSearchBar.jsx b/src/components/Search/SmartSearchBar.jsx new file mode 100644 index 0000000..0a66945 --- /dev/null +++ b/src/components/Search/SmartSearchBar.jsx @@ -0,0 +1,206 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { recoSearch } from "../../services/recoSearch"; +import "../../styles/smartSearchBar.css"; + +function defaultZoomForKind(kind) { + const k = (kind || "").toLowerCase(); + if (k.includes("post")) return 15; + if (k.includes("place") || k.includes("poi")) return 13; + if (k.includes("region") || k.includes("area") || k.includes("city")) return 9; + return 12; +} + +function IconPin(props) { + return ( + + ); +} + +function IconWaves(props) { + return ( + + ); +} + +/** + * Props: + * - placeholder + * - onPick(item, { zoom, reason }) // call when user selects suggestion + * - onMySpot() // keep same function (center on user) + * - onPlaceTourWire() // keep same function (place/tour/wire) + */ +export default function SmartSearchBar({ + placeholder = "Search places, regions, posts…", + onPick, + onMySpot, + onPlaceTourWire, +}) { + const [q, setQ] = useState(""); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [items, setItems] = useState([]); + const [active, setActive] = useState(-1); + const [err, setErr] = useState(""); + + const abortRef = useRef(null); + const boxRef = useRef(null); + + useEffect(() => { + function onDocDown(e) { + if (!boxRef.current) return; + if (!boxRef.current.contains(e.target)) setOpen(false); + } + document.addEventListener("mousedown", onDocDown); + return () => document.removeEventListener("mousedown", onDocDown); + }, []); + + useEffect(() => { + const query = q.trim(); + setErr(""); + + if (abortRef.current) abortRef.current.abort(); + if (!query) { + setItems([]); + setLoading(false); + setActive(-1); + return; + } + + const ctrl = new AbortController(); + abortRef.current = ctrl; + setLoading(true); + + const t = setTimeout(async () => { + try { + const res = await recoSearch(query, { signal: ctrl.signal, limit: 10 }); + setItems(res); + setOpen(true); + setActive(res.length ? 0 : -1); + } catch (e) { + if (ctrl.signal.aborted) return; + setErr((e && e.message) ? e.message : "Search error"); + setItems([]); + } finally { + if (!ctrl.signal.aborted) setLoading(false); + } + }, 220); + + return () => { + clearTimeout(t); + ctrl.abort(); + }; + }, [q]); + + const showList = open && (items.length > 0 || loading || err); + + const pick = (it, reason = "click") => { + if (!it) return; + const zoom = defaultZoomForKind(it.kind); + setOpen(false); + setQ(it.title || q); + + onPick && onPick(it, { zoom, reason }); + }; + + const onKeyDown = (e) => { + if (!showList && (e.key === "ArrowDown" || e.key === "Enter")) setOpen(true); + + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((i) => Math.min(i + 1, items.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + if (items.length && active >= 0) { + e.preventDefault(); + pick(items[active], "enter"); + } + } else if (e.key === "Escape") { + setOpen(false); + } + }; + + const rightTitle = useMemo(() => { + // visuel flexible, même fonction: tu peux renommer plus tard + return "Place / Tour / Wire"; + }, []); + + return ( +
+
+ setQ(e.target.value)} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + placeholder={placeholder} + aria-label="Search" + /> + +
+ + + +
+
+ + {showList && ( +
+ {loading &&
Searching…
} + {err &&
{err}
} + + {!loading && !err && items.map((it, idx) => ( + + ))} + + {!loading && !err && items.length === 0 && ( +
No results
+ )} +
+ )} +
+ ); +} diff --git a/src/styles/smartSearchBar.css b/src/styles/smartSearchBar.css new file mode 100644 index 0000000..cae51f8 --- /dev/null +++ b/src/styles/smartSearchBar.css @@ -0,0 +1,118 @@ +/* SmartSearchBar — built to play nice with themes (uses CSS variables with fallbacks) */ + +.sw-search { + position: relative; + width: 100%; + max-width: 560px; +} + +.sw-search__bar { + position: relative; + display: flex; + align-items: center; + gap: 8px; + + border-radius: 14px; + padding: 8px 10px; + + background: var(--sw-panel-bg, rgba(15, 18, 28, 0.62)); + border: 1px solid var(--sw-panel-border, rgba(255, 255, 255, 0.10)); + backdrop-filter: blur(10px); +} + +.sw-search__input { + flex: 1; + border: 0; + outline: 0; + background: transparent; + + color: var(--sw-text, rgba(255,255,255,0.92)); + font-size: 14px; + padding: 2px 4px; +} + +.sw-search__input::placeholder { + color: var(--sw-muted, rgba(255,255,255,0.55)); +} + +.sw-search__icons { + display: flex; + align-items: center; + gap: 6px; +} + +.sw-search__iconBtn { + border: 0; + outline: 0; + cursor: pointer; + + width: 34px; + height: 34px; + border-radius: 12px; + + display: inline-flex; + align-items: center; + justify-content: center; + + background: var(--sw-chip-bg, rgba(255,255,255,0.08)); + color: var(--sw-text, rgba(255,255,255,0.92)); +} + +.sw-search__iconBtn:active { + transform: translateY(1px); +} + +.sw-search__dropdown { + position: absolute; + z-index: 50; + left: 0; + right: 0; + margin-top: 8px; + + border-radius: 14px; + overflow: hidden; + + background: var(--sw-panel-bg-2, rgba(12, 14, 22, 0.80)); + border: 1px solid var(--sw-panel-border, rgba(255, 255, 255, 0.10)); + backdrop-filter: blur(12px); +} + +.sw-search__row { + padding: 10px 12px; + text-align: left; +} + +.sw-search__item { + width: 100%; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; +} + +.sw-search__item:hover, +.sw-search__item.is-active { + background: var(--sw-hover, rgba(255,255,255,0.08)); +} + +.sw-search__title { + color: var(--sw-text, rgba(255,255,255,0.92)); + font-size: 13.5px; + line-height: 1.2; +} + +.sw-search__sub { + margin-top: 2px; + color: var(--sw-muted, rgba(255,255,255,0.60)); + font-size: 12px; +} + +.sw-search__muted { + color: var(--sw-muted, rgba(255,255,255,0.60)); + font-size: 12.5px; +} + +.sw-search__error { + color: var(--sw-danger, #ff6b6b); + font-size: 12.5px; +}