bdf9cupe3: Add expert text search and category browse to the left column
This commit is contained in:
@@ -7,6 +7,7 @@ import { SignInForm } from "./SignInForm";
|
||||
import { Logo } from "./Logo";
|
||||
import { Feed } from "./Feed";
|
||||
import { DiscoverRails } from "./DiscoverRails";
|
||||
import { ExpertSearch } from "./ExpertSearch";
|
||||
|
||||
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
|
||||
|
||||
@@ -53,6 +54,7 @@ export function App() {
|
||||
<ul>
|
||||
<li><Link href="/" aria-current="page">Feed</Link></li>
|
||||
</ul>
|
||||
<ExpertSearch />
|
||||
</nav>
|
||||
<div className="col-center">
|
||||
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user