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