132 lines
4.4 KiB
TypeScript
132 lines
4.4 KiB
TypeScript
"use client";
|
|
|
|
import { HexAvatar } from "@/components/HexAvatar";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import type { DiscoverCategory } from "@/lib/discover";
|
|
import { fetchDiscover } from "@/lib/discover-client";
|
|
import type { ExpertResult } from "@/lib/experts-search";
|
|
import { fetchExperts } from "@/lib/experts-search-client";
|
|
|
|
export type SearchStatus = "idle" | "loading" | "ready" | "error";
|
|
|
|
export interface ExpertSearchViewProps {
|
|
query: string;
|
|
onQuery: (q: string) => void;
|
|
categories: DiscoverCategory[];
|
|
activeCategory: string | null;
|
|
onCategory: (name: string | null) => void;
|
|
status: SearchStatus;
|
|
results: ExpertResult[];
|
|
defaultOpen?: boolean;
|
|
}
|
|
|
|
export function ExpertSearchView(p: ExpertSearchViewProps) {
|
|
return (
|
|
<details className="expert-search" open={p.defaultOpen}>
|
|
<summary>Find experts</summary>
|
|
<form role="search" onSubmit={(e) => e.preventDefault()}>
|
|
<label htmlFor="expert-q">Search experts</label>
|
|
<input
|
|
id="expert-q"
|
|
type="search"
|
|
value={p.query}
|
|
placeholder="Name or topic"
|
|
autoComplete="off"
|
|
maxLength={80}
|
|
onChange={(e) => p.onQuery(e.target.value)}
|
|
/>
|
|
</form>
|
|
{p.categories.length > 0 ? (
|
|
<div role="group" aria-label="Browse experts by category" className="chips">
|
|
{p.categories.map((c) => (
|
|
<button
|
|
key={String(c.id)}
|
|
type="button"
|
|
className="chip"
|
|
aria-pressed={p.activeCategory === c.name}
|
|
onClick={() => p.onCategory(p.activeCategory === c.name ? null : c.name)}
|
|
>
|
|
{c.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<div aria-live="polite" className="search-results">
|
|
{p.status === "loading" ? <p className="search-note">Searching...</p> : null}
|
|
{p.status === "error" ? <p className="search-note" role="alert">Search is unavailable. Try again.</p> : null}
|
|
{p.status === "ready" && p.results.length === 0 ? (
|
|
<p className="search-note">No experts found.</p>
|
|
) : null}
|
|
{p.status === "ready" && p.results.length > 0 ? (
|
|
<ul className="rail-list">
|
|
{p.results.map((e) => (
|
|
<li key={String(e.id)} className="rail-card expert-card" data-testid="search-result">
|
|
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
|
|
<div className="rail-text">
|
|
<strong className="rail-name">{e.displayName || "Expert"}</strong>
|
|
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
|
|
{e.rating !== null ? <span className="rail-meta">★ {e.rating.toFixed(1)}</span> : null}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
const DEBOUNCE_MS = 300;
|
|
|
|
export function ExpertSearch() {
|
|
const [query, setQuery] = useState("");
|
|
const [category, setCategory] = useState<string | null>(null);
|
|
const [categories, setCategories] = useState<DiscoverCategory[]>([]);
|
|
const [status, setStatus] = useState<SearchStatus>("idle");
|
|
const [results, setResults] = useState<ExpertResult[]>([]);
|
|
const [open, setOpen] = useState(true);
|
|
const seq = useRef(0);
|
|
|
|
useEffect(() => {
|
|
setOpen(window.matchMedia("(min-width: 960px)").matches);
|
|
let live = true;
|
|
fetchDiscover().then(
|
|
(d) => { if (live) setCategories(d.categories); },
|
|
() => {},
|
|
);
|
|
return () => { live = false; };
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const q = query.trim();
|
|
const mine = ++seq.current;
|
|
if (!q && !category) {
|
|
setStatus("idle");
|
|
setResults([]);
|
|
return;
|
|
}
|
|
setStatus("loading");
|
|
const t = setTimeout(() => {
|
|
fetchExperts(q ? { q } : { category: category ?? undefined }).then(
|
|
(r) => { if (seq.current === mine) { setResults(r); setStatus("ready"); } },
|
|
() => { if (seq.current === mine) { setResults([]); setStatus("error"); } },
|
|
);
|
|
}, q ? DEBOUNCE_MS : 0);
|
|
return () => clearTimeout(t);
|
|
}, [query, category]);
|
|
|
|
return (
|
|
<ExpertSearchView
|
|
key={open ? "open" : "closed"}
|
|
defaultOpen={open}
|
|
query={query}
|
|
onQuery={setQuery}
|
|
categories={categories}
|
|
activeCategory={category}
|
|
onCategory={setCategory}
|
|
status={status}
|
|
results={results}
|
|
/>
|
|
);
|
|
}
|