feat(web): /search two-pane screen (debounced query, visibility filter, load more) + nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useSearch } from "../api/queries";
|
||||
import { useDebouncedValue } from "../lib/use-debounced-value";
|
||||
import { SearchResultRow } from "./search-result-row";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const VIS = ["all", "draft", "internal", "public"] as const;
|
||||
|
||||
export function SearchPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [text, setText] = useState(() => params.get("q") ?? "");
|
||||
const visibility = params.get("visibility"); // null == "all"
|
||||
const debounced = useDebouncedValue(text, 300);
|
||||
|
||||
useEffect(() => {
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
const term = debounced.trim();
|
||||
|
||||
if (term) next.set("q", term);
|
||||
else next.delete("q");
|
||||
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [debounced, setParams]);
|
||||
|
||||
const search = useSearch(debounced, visibility);
|
||||
|
||||
const setVisibility = (value: string) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
|
||||
if (value === "all") next.delete("visibility");
|
||||
else next.set("visibility", value);
|
||||
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
const hits = search.data?.pages.flatMap((page) => page.hits) ?? [];
|
||||
const total = search.data?.pages[0]?.estimated_total ?? 0;
|
||||
const hasQuery = debounced.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="space-y-2 border-b p-3">
|
||||
<Input
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
placeholder={t("search.placeholder")}
|
||||
aria-label={t("search.placeholder")}
|
||||
/>
|
||||
<div className="flex gap-1 text-xs">
|
||||
{VIS.map((value) => {
|
||||
const active = (visibility ?? "all") === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setVisibility(value)}
|
||||
className={`rounded px-2 py-0.5 ${active ? "bg-indigo-600 text-white" : "border"}`}
|
||||
>
|
||||
{value === "all" ? t("search.all") : t(`visibility.${value}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{!hasQuery && <p className="p-4 text-sm text-neutral-400">{t("search.prompt")}</p>}
|
||||
|
||||
{hasQuery && search.isLoading && (
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasQuery && search.isError && (
|
||||
<p className="p-4 text-sm text-red-600">{t("search.loadError")}</p>
|
||||
)}
|
||||
|
||||
{hasQuery && !search.isLoading && !search.isError && hits.length === 0 && (
|
||||
<p className="p-4 text-sm text-neutral-500">{t("search.empty")}</p>
|
||||
)}
|
||||
|
||||
{hits.length > 0 && (
|
||||
<>
|
||||
<p className="px-3 pt-2 text-xs text-neutral-500">
|
||||
{t("search.resultCount", { count: total })}
|
||||
</p>
|
||||
<ul>
|
||||
{hits.map((hit) => (
|
||||
<SearchResultRow key={hit.id} hit={hit} />
|
||||
))}
|
||||
</ul>
|
||||
{search.hasNextPage && (
|
||||
<div className="p-3 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={search.isFetchingNextPage}
|
||||
onClick={() => search.fetchNextPage()}
|
||||
>
|
||||
{t("search.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user