bdf9cupe3: Add expert text search and category browse to the left column

This commit is contained in:
2026-10-19 17:33:43 -06:00
parent b97a77471b
commit 5b70bbfefa
7 changed files with 463 additions and 2 deletions
+136
View File
@@ -0,0 +1,136 @@
"use client";
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";
import { initials } from "@/lib/feed-model";
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">
{e.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar" src={e.pictureUrl} alt="" width={40} height={40} />
) : (
<span className="avatar avatar-initials" aria-hidden="true">{initials(e.displayName)}</span>
)}
<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}
/>
);
}