update frontend

This commit is contained in:
Your Name 2025-12-20 22:32:11 -05:00
parent 1565d1c72d
commit 86d6f153c6
2 changed files with 324 additions and 0 deletions

View File

@ -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>
);
}

View File

@ -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;
}