137 lines
4.9 KiB
TypeScript
137 lines
4.9 KiB
TypeScript
"use client";
|
|
|
|
import { SweepButton } from "@/components/Buttons";
|
|
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[];
|
|
}
|
|
|
|
export function ExpertSearchView(p: ExpertSearchViewProps) {
|
|
return (
|
|
<section className="tile panel expert-search" aria-labelledby="h-find">
|
|
<div className="panel-head">
|
|
<h2 id="h-find" className="type-title type-title-strong">Find experts</h2>
|
|
</div>
|
|
<form role="search" className="search-form" onSubmit={(e) => e.preventDefault()}>
|
|
<label htmlFor="expert-q">Search experts</label>
|
|
<div className="search-field">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
|
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
|
|
</svg>
|
|
<input
|
|
id="expert-q"
|
|
type="search"
|
|
value={p.query}
|
|
placeholder="Name or topic"
|
|
autoComplete="off"
|
|
maxLength={80}
|
|
onChange={(e) => p.onQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
</form>
|
|
{p.categories.length > 0 ? (
|
|
<div role="group" aria-label="Browse experts by category" className="chips">
|
|
{p.categories.map((c) => (
|
|
<SweepButton
|
|
key={String(c.id)}
|
|
label={c.name}
|
|
selected={p.activeCategory === c.name}
|
|
onClick={() => p.onCategory(p.activeCategory === c.name ? null : c.name)}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<div aria-live="polite" className="search-results panel-scroll fade-edges">
|
|
{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 search-rows">
|
|
{p.results.map((e) => (
|
|
<li key={String(e.id)} className="expert-row" data-testid="search-result">
|
|
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
|
|
<div className="expert-text">
|
|
<strong>{e.displayName || "Expert"}</strong>
|
|
{e.byline ? <span>{e.byline}</span> : null}
|
|
</div>
|
|
{e.rating !== null ? (
|
|
<span className="expert-rate" aria-label={`Rating ${e.rating.toFixed(1)}`}>
|
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m12 2.8 2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.6l-5.8 3.1 1.1-6.5L2.6 9.6l6.5-.9L12 2.8Z" /></svg>
|
|
{e.rating.toFixed(1)}
|
|
</span>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
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 seq = useRef(0);
|
|
|
|
useEffect(() => {
|
|
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
|
|
query={query}
|
|
onQuery={setQuery}
|
|
categories={categories}
|
|
activeCategory={category}
|
|
onCategory={setCategory}
|
|
status={status}
|
|
results={results}
|
|
/>
|
|
);
|
|
}
|