diff --git a/web/src/app.tsx b/web/src/app.tsx
index e882e83..952925d 100644
--- a/web/src/app.tsx
+++ b/web/src/app.tsx
@@ -7,6 +7,8 @@ import { AppShell } from "./shell/app-shell";
import { ObjectsPage } from "./objects/objects-page";
import { ObjectDetail } from "./objects/object-detail";
import { SelectPrompt } from "./objects/select-prompt";
+import { SearchPage } from "./search/search-page";
+import { SelectSearchPrompt } from "./search/select-search-prompt";
import { VocabulariesPage } from "./vocab/vocabularies-page";
import { VocabularyTerms } from "./vocab/vocabulary-terms";
import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt";
@@ -55,6 +57,10 @@ export function App() {
} />
} />
+ }>
+ } />
+ } />
+
} />
} />
} />
diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json
index eb1d289..9706f75 100644
--- a/web/src/i18n/en.json
+++ b/web/src/i18n/en.json
@@ -18,6 +18,16 @@
"title": "Authorities", "person": "Person", "organisation": "Organisation", "place": "Place",
"new": "New", "create": "Create", "empty": "No authorities yet", "loadError": "Could not load"
},
+ "search": {
+ "placeholder": "Search the collection…",
+ "all": "All",
+ "prompt": "Type to search",
+ "empty": "No results",
+ "loadError": "Search is unavailable",
+ "loadMore": "Load more",
+ "resultCount": "{{count}} results",
+ "selectPrompt": "Select a result to see the full record"
+ },
"publish": {
"heading": "Visibility",
"advanceInternal": "Advance to internal",
diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json
index 56e4f7e..574c995 100644
--- a/web/src/i18n/sv.json
+++ b/web/src/i18n/sv.json
@@ -18,6 +18,16 @@
"title": "Auktoriteter", "person": "Person", "organisation": "Organisation", "place": "Plats",
"new": "Ny", "create": "Skapa", "empty": "Inga auktoriteter ännu", "loadError": "Kunde inte ladda"
},
+ "search": {
+ "placeholder": "Sök i samlingen…",
+ "all": "Alla",
+ "prompt": "Skriv för att söka",
+ "empty": "Inga träffar",
+ "loadError": "Sök är inte tillgängligt",
+ "loadMore": "Visa fler",
+ "resultCount": "{{count}} träffar",
+ "selectPrompt": "Välj en träff för att se hela posten"
+ },
"publish": {
"heading": "Synlighet",
"advanceInternal": "Gör intern",
diff --git a/web/src/search/search-page.tsx b/web/src/search/search-page.tsx
new file mode 100644
index 0000000..41ac0ed
--- /dev/null
+++ b/web/src/search/search-page.tsx
@@ -0,0 +1,16 @@
+import { Outlet } from "react-router-dom";
+
+import { SearchPanel } from "./search-panel";
+
+export function SearchPage() {
+ return (
+
+ );
+}
diff --git a/web/src/search/search-panel.tsx b/web/src/search/search-panel.tsx
new file mode 100644
index 0000000..e540b57
--- /dev/null
+++ b/web/src/search/search-panel.tsx
@@ -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 (
+
+
+
setText(event.target.value)}
+ placeholder={t("search.placeholder")}
+ aria-label={t("search.placeholder")}
+ />
+
+ {VIS.map((value) => {
+ const active = (visibility ?? "all") === value;
+
+ return (
+
+ );
+ })}
+
+
+
+
+ {!hasQuery &&
{t("search.prompt")}
}
+
+ {hasQuery && search.isLoading && (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {hasQuery && search.isError && (
+
{t("search.loadError")}
+ )}
+
+ {hasQuery && !search.isLoading && !search.isError && hits.length === 0 && (
+
{t("search.empty")}
+ )}
+
+ {hits.length > 0 && (
+ <>
+
+ {t("search.resultCount", { count: total })}
+
+
+ {hits.map((hit) => (
+
+ ))}
+
+ {search.hasNextPage && (
+
+
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/web/src/search/search.test.tsx b/web/src/search/search.test.tsx
new file mode 100644
index 0000000..22a2f19
--- /dev/null
+++ b/web/src/search/search.test.tsx
@@ -0,0 +1,77 @@
+import { expect, test } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { http, HttpResponse } from "msw";
+import { Route, Routes } from "react-router-dom";
+
+import { server } from "../test/server";
+import { renderApp } from "../test/render";
+import { amphora } from "../test/fixtures";
+import { SearchPage } from "./search-page";
+import { SelectSearchPrompt } from "./select-search-prompt";
+import { ObjectDetail } from "../objects/object-detail";
+
+function tree() {
+ return (
+
+ }>
+ } />
+ } />
+
+
+ );
+}
+
+test("typing searches and renders highlighted rich rows", async () => {
+ renderApp(tree(), { route: "/search" });
+ await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
+
+ expect(await screen.findByText("Bronze figurine")).toBeInTheDocument();
+ const mark = await screen.findByText("bronze");
+ expect(mark.tagName).toBe("MARK");
+ expect(screen.getByText(/25 results/i)).toBeInTheDocument();
+});
+
+test("Load more appends the next page", async () => {
+ renderApp(tree(), { route: "/search" });
+ await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
+ await screen.findByText("Bronze figurine");
+
+ expect(screen.queryByText("Object 21")).toBeNull();
+ await userEvent.click(screen.getByRole("button", { name: /load more/i }));
+ expect(await screen.findByText("Object 21")).toBeInTheDocument();
+});
+
+test("the visibility filter adds the param to the request", async () => {
+ let lastVisibility: string | null = "unset";
+ server.use(
+ http.get("/api/admin/search", ({ request }) => {
+ lastVisibility = new URL(request.url).searchParams.get("visibility");
+ return HttpResponse.json({ hits: [], estimated_total: 0 });
+ }),
+ );
+ renderApp(tree(), { route: "/search" });
+ await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
+ await userEvent.click(screen.getByRole("button", { name: /^draft$/i }));
+
+ await waitFor(() => expect(lastVisibility).toBe("draft"));
+});
+
+test("empty query shows the prompt; zero results shows empty", async () => {
+ renderApp(tree(), { route: "/search" });
+ expect(screen.getByText(/type to search/i)).toBeInTheDocument();
+
+ server.use(
+ http.get("/api/admin/search", () => HttpResponse.json({ hits: [], estimated_total: 0 })),
+ );
+ await userEvent.type(screen.getByLabelText(/search the collection/i), "zzz");
+ expect(await screen.findByText(/no results/i)).toBeInTheDocument();
+});
+
+test("clicking a result shows the object in the detail pane", async () => {
+ renderApp(tree(), { route: "/search" });
+ await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
+ await userEvent.click(await screen.findByText("Bronze figurine"));
+
+ expect(await screen.findByText(amphora.object_name)).toBeInTheDocument();
+});
diff --git a/web/src/search/select-search-prompt.tsx b/web/src/search/select-search-prompt.tsx
new file mode 100644
index 0000000..d5aa715
--- /dev/null
+++ b/web/src/search/select-search-prompt.tsx
@@ -0,0 +1,11 @@
+import { useTranslation } from "react-i18next";
+
+export function SelectSearchPrompt() {
+ const { t } = useTranslation();
+
+ return (
+
+ {t("search.selectPrompt")}
+
+ );
+}
diff --git a/web/src/shell/app-shell.test.tsx b/web/src/shell/app-shell.test.tsx
index 1e58ec9..9cc1ca2 100644
--- a/web/src/shell/app-shell.test.tsx
+++ b/web/src/shell/app-shell.test.tsx
@@ -29,8 +29,9 @@ test("shows active and disabled nav and renders the outlet", async () => {
renderApp(tree(), { route: "/objects" });
expect(await screen.findByText("objects outlet")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /objects/i })).toBeInTheDocument();
- // later milestones are present but disabled
- expect(screen.getByRole("button", { name: /search/i })).toBeDisabled();
+ // fields is still disabled; search is now a link
+ expect(screen.getByRole("link", { name: /search/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /fields/i })).toBeDisabled();
});
test("language switch toggles to Swedish", async () => {
diff --git a/web/src/shell/app-shell.tsx b/web/src/shell/app-shell.tsx
index 1ab6a0c..4a1aa13 100644
--- a/web/src/shell/app-shell.tsx
+++ b/web/src/shell/app-shell.tsx
@@ -5,7 +5,7 @@ import { useLogout } from "../api/queries";
import { Button } from "@/components/ui/button";
import { LangSwitch } from "./lang-switch";
-const DISABLED_NAV = ["fields", "search"] as const;
+const DISABLED_NAV = ["fields"] as const;
export function AppShell() {
const { t } = useTranslation();
@@ -46,6 +46,14 @@ export function AppShell() {
>
{t("nav.authorities")}
+
+ `block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
+ }
+ >
+ {t("nav.search")}
+
{DISABLED_NAV.map((key) => (