update frontend
This commit is contained in:
parent
1565d1c72d
commit
86d6f153c6
|
|
@ -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 (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="M12 22s7-6.1 7-13a7 7 0 1 0-14 0c0 6.9 7 13 7 13zm0-9.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconWaves(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="M3 10c2 0 2-2 4-2s2 2 4 2 2-2 4-2 2 2 4 2 2-2 4-2v2c-2 0-2 2-4 2s-2-2-4-2-2 2-4 2-2-2-4-2-2 2-4 2V10z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M3 14c2 0 2-2 4-2s2 2 4 2 2-2 4-2 2 2 4 2 2-2 4-2v2c-2 0-2 2-4 2s-2-2-4-2-2 2-4 2-2-2-4-2-2 2-4 2v-2z"
|
||||
fill="currentColor"
|
||||
opacity="0.75"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="sw-search" ref={boxRef}>
|
||||
<div className="sw-search__bar">
|
||||
<input
|
||||
className="sw-search__input"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
aria-label="Search"
|
||||
/>
|
||||
|
||||
<div className="sw-search__icons" aria-label="Search actions">
|
||||
<button
|
||||
type="button"
|
||||
className="sw-search__iconBtn"
|
||||
title={rightTitle}
|
||||
onClick={() => onPlaceTourWire && onPlaceTourWire()}
|
||||
>
|
||||
<IconWaves />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="sw-search__iconBtn"
|
||||
title="My spot"
|
||||
onClick={() => onMySpot && onMySpot()}
|
||||
>
|
||||
<IconPin />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showList && (
|
||||
<div className="sw-search__dropdown">
|
||||
{loading && <div className="sw-search__row sw-search__muted">Searching…</div>}
|
||||
{err && <div className="sw-search__row sw-search__error">{err}</div>}
|
||||
|
||||
{!loading && !err && items.map((it, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={it.id}
|
||||
className={"sw-search__row sw-search__item" + (idx === active ? " is-active" : "")}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
onClick={() => pick(it, "click")}
|
||||
>
|
||||
<div className="sw-search__title">{it.title || "Untitled"}</div>
|
||||
<div className="sw-search__sub">
|
||||
{(it.kind ? it.kind : "result")}
|
||||
{it.subtitle ? " • " + it.subtitle : ""}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{!loading && !err && items.length === 0 && (
|
||||
<div className="sw-search__row sw-search__muted">No results</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue