134 lines
4.1 KiB
TypeScript
134 lines
4.1 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { useSearch, HttpError } 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"
|
|
aria-pressed={active}
|
|
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">
|
|
{search.error instanceof HttpError && search.error.status === 503
|
|
? t("search.unavailable")
|
|
: 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>
|
|
);
|
|
}
|