Add search UI with scoping and snippets (#50)
All checks were successful
CD / Build and push images (push) Successful in 3m24s
CI / Lint, typecheck, test (push) Successful in 2m16s
CI / Auth e2e pack (push) Successful in 2m52s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s

A search palette over the #49 full-text search.

- web:
  - `SearchPalette`: opened from a top-bar button or the global "/" shortcut
    (ignored while typing in a field). Scoped to the current pond by default
    with an "all my ponds" toggle and, when scoped, a label filter. Results
    list title, pond, label chips, and a highlighted snippet; recent searches
    (localStorage) show before typing; empty/error/hint states.
  - `HighlightedSnippet` renders the match — the api wraps hits in shared
    sentinels (private-use codepoints), split here into `<mark>` so no HTML
    from the content is interpreted.
  - Fully keyboard-operable: "/" opens, ↑/↓ move, Enter opens the page,
    Esc closes.
  - i18n `search` namespace (de + en); palette + result styles.
- api: a pondId scope test proves search narrows to one pond.
- e2e `search.spec.ts` (new CI pack): body content added via the editor is
  found and highlighted, the scope toggle keeps the result, and Enter opens
  the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
Claude Opus 4.8 2026-07-09 13:36:06 +02:00
parent 91dfccf226
commit 19fb24c527
10 changed files with 565 additions and 1 deletions

View File

@ -204,6 +204,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/backlinks.spec.ts
- name: Reset login rate limit before search pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run search pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/search.spec.ts
- name: Dump server logs on failure
if: failure()
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true

View File

@ -106,6 +106,42 @@ describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
expect(results).toEqual([]);
});
it('scopes results to a single pond when pondId is given', async () => {
// A second pond of the same owner with a page matching the same term.
const other = await prisma.pond.create({
data: {
slug: `srch-pond2-${suffix}`,
name: 'Second Pond',
type: 'SHARED',
ownerId: owner.id,
},
});
const otherPage = await prisma.page.create({
data: {
id: randomUUID(),
pondId: other.id,
title: `${term} elsewhere`,
slug: `q-${suffix}`,
ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())),
sortKey: 'a0',
createdBy: owner.id,
contentCache: { create: { plainText: '', markdown: '', html: '', outline: [] } },
},
});
await search.indexPage(otherPage.id);
// All ponds: the second pond's page is included.
const all = await search.search({ q: term }, owner);
expect(all.map((r) => r.pageId)).toContain(otherPage.id);
// Scoped to the first pond: it is excluded.
const scoped = await search.search({ q: term, pondId }, owner);
expect(scoped.map((r) => r.pondId).every((id) => id === pondId)).toBe(true);
expect(scoped.map((r) => r.pageId)).not.toContain(otherPage.id);
await prisma.page.deleteMany({ where: { pondId: other.id } });
await prisma.pond.deleteMany({ where: { id: other.id } });
});
it('reindexAll rebuilds from the cache and is idempotent', async () => {
const before = await search.search({ q: term }, owner);
await search.reindexAll();

View File

@ -0,0 +1,65 @@
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Search UI pack (issue #50). Adds body content to a page through the editor
* (indexed by #49 via the collab persistence hook), then opens the search
* palette with the "/" shortcut and checks that the content is found, the match
* is highlighted, the scope toggle works, and Enter opens the page.
* Language-independent selectors (CSS classes + unique content).
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
type Ctx = Awaited<ReturnType<typeof contextForUser>>;
async function personalPond(context: Ctx): Promise<{ id: string; slug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
return { id: pond.id, slug: pond.slug };
}
test('search finds page content, highlights it, and is keyboard-operable', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const ts = Date.now();
const word = `srchword${ts}`;
const title = `Search Page ${ts}`;
const created = await (
await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { data: { title } })
).json();
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${created.slug}`);
// Add body content through the editor, then reload to flush it to the index.
await page.locator('.editor-page__mode-toggle').click();
const body = page.locator('.editor-content .ProseMirror');
await body.click();
await page.keyboard.type(`the unique ${word} lives in this body`);
await page.reload();
await expect(page.locator('.editor-content')).toContainText(word);
// Leave edit mode so "/" is a shortcut, not editor input.
await page.locator('.editor-page__mode-toggle').click();
// Open search with the "/" shortcut.
await page.keyboard.press('/');
const palette = page.locator('.search-palette');
await expect(palette).toBeVisible();
await palette.locator('.search-palette__input').fill(word);
const result = page.locator('.search-result', { hasText: title });
await expect(result).toBeVisible();
// The matched word is highlighted in the snippet.
await expect(page.locator('.search-result__snippet mark')).toBeVisible();
// Scope toggle is present and keeps the result (page is in the user's ponds).
await palette.locator('.search-palette__scope input[type="checkbox"]').first().check();
await expect(page.locator('.search-result', { hasText: title })).toBeVisible();
// Enter opens the selected result.
await palette.locator('.search-palette__input').press('Enter');
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/${created.slug}$`));
await context.close();
});

View File

@ -4,6 +4,7 @@ import deEditor from '@dorfteich/shared/i18n/de/editor.json';
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
import deLinks from '@dorfteich/shared/i18n/de/links.json';
import deSearch from '@dorfteich/shared/i18n/de/search.json';
import deSettings from '@dorfteich/shared/i18n/de/settings.json';
import enAuth from '@dorfteich/shared/i18n/en/auth.json';
import enCommon from '@dorfteich/shared/i18n/en/common.json';
@ -11,6 +12,7 @@ import enEditor from '@dorfteich/shared/i18n/en/editor.json';
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
import enLinks from '@dorfteich/shared/i18n/en/links.json';
import enSearch from '@dorfteich/shared/i18n/en/search.json';
import enSettings from '@dorfteich/shared/i18n/en/settings.json';
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
@ -35,6 +37,7 @@ void i18n
editor: enEditor,
labels: enLabels,
links: enLinks,
search: enSearch,
},
de: {
common: deCommon,
@ -44,6 +47,7 @@ void i18n
editor: deEditor,
labels: deLabels,
links: deLinks,
search: deSearch,
},
},
defaultNS: 'common',

View File

@ -1,10 +1,18 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { SearchPalette } from '../search/SearchPalette';
import { PondSwitcher } from './PondSwitcher';
/** True when focus is in a field where "/" should type, not open search. */
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
}
interface TopBarProps {
sidebarCollapsed: boolean;
onToggleSidebar: () => void;
@ -15,6 +23,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
const { user, logout } = useAuth();
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
async function handleLogout(): Promise<void> {
setMenuOpen(false);
@ -22,6 +31,19 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
navigate('/login');
}
// Global "/" shortcut opens search (unless typing in a field) — issue #50.
useEffect(() => {
if (!user) return undefined;
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === '/' && !isTypingTarget(event.target) && !searchOpen) {
event.preventDefault();
setSearchOpen(true);
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [user, searchOpen]);
return (
<header className="topbar">
<button
@ -39,6 +61,17 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
</Link>
{user && <PondSwitcher />}
<span className="topbar__spacer" />
{user && (
<button
type="button"
className="topbar__search"
onClick={() => setSearchOpen(true)}
aria-label={t('search:open')}
>
<span aria-hidden>🔍</span> {t('search:open')}
</button>
)}
{searchOpen && user && <SearchPalette onClose={() => setSearchOpen(false)} />}
{user ? (
<div className="user-menu">
<button

View File

@ -0,0 +1,211 @@
import type { PondView, SearchResultView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { LabelChips } from '../labels/LabelChips';
import { usePondLabels } from '../labels/use-pond-labels';
import { useCurrentPondRoute } from '../layout/use-pond-route';
import { apiGet } from '../lib/api';
import { HighlightedSnippet } from './highlight';
const RECENT_KEY = 'dorfteich.recentSearches';
const RECENT_MAX = 5;
function loadRecent(): string[] {
try {
const raw = localStorage.getItem(RECENT_KEY);
const parsed = raw ? (JSON.parse(raw) as unknown) : [];
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
function saveRecent(query: string): string[] {
const next = [query, ...loadRecent().filter((q) => q !== query)].slice(0, RECENT_MAX);
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
} catch {
/* ignore quota/availability errors */
}
return next;
}
/**
* Full-text search palette (issue #50, ADR 0010). Opens over the app; scoped to
* the current pond by default with an "all my ponds" toggle and an optional
* label filter. Results show title, pond, label chips, and a highlighted
* snippet; Enter opens the selected page. Fully keyboard-operable (/ to move,
* Enter to open, Esc to close) and remembers recent searches in localStorage.
*/
export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.Element {
const { t } = useTranslation('search');
const navigate = useNavigate();
const { pondSlug } = useCurrentPondRoute();
const inputRef = useRef<HTMLInputElement>(null);
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
enabled: pondSlug !== null,
});
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
// Default: search the pond you are in; fall back to all ponds off a pond route.
const [allPonds, setAllPonds] = useState(pondSlug === null);
const [labelIds, setLabelIds] = useState<string[]>([]);
const [selected, setSelected] = useState(0);
const [recent, setRecent] = useState<string[]>(() => loadRecent());
const scopePondId = allPonds ? undefined : pond.data?.id;
const { flat, byId } = usePondLabels(scopePondId);
useEffect(() => inputRef.current?.focus(), []);
useEffect(() => {
const id = setTimeout(() => setDebounced(query.trim()), 200);
return () => clearTimeout(id);
}, [query]);
useEffect(() => setSelected(0), [debounced, allPonds, labelIds]);
const results = useQuery({
queryKey: ['search', debounced, scopePondId ?? 'all', labelIds],
queryFn: () => {
const params = new URLSearchParams({ q: debounced });
if (scopePondId) params.set('pondId', scopePondId);
if (labelIds.length > 0) params.set('labels', labelIds.join(','));
return apiGet<SearchResultView[]>(`/search?${params.toString()}`);
},
enabled: debounced.length > 0,
});
const hits = useMemo(() => results.data ?? [], [results.data]);
function open(hit: SearchResultView): void {
setRecent(saveRecent(debounced));
onClose();
navigate(`/p/${hit.pondSlug}/${hit.slug}`);
}
function onKeyDown(event: React.KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
setSelected((i) => (hits.length === 0 ? 0 : (i + 1) % hits.length));
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setSelected((i) => (hits.length === 0 ? 0 : (i - 1 + hits.length) % hits.length));
} else if (event.key === 'Enter') {
event.preventDefault();
const hit = hits[selected];
if (hit) open(hit);
}
}
function toggleLabel(id: string, on: boolean): void {
setLabelIds((prev) => (on ? [...prev, id] : prev.filter((x) => x !== id)));
}
return (
<div className="search-overlay" role="dialog" aria-modal="true" aria-label={t('title')}>
<div className="search-backdrop" onClick={onClose} aria-hidden />
<div className="search-palette" onKeyDown={onKeyDown}>
<input
ref={inputRef}
type="search"
className="search-palette__input"
value={query}
placeholder={t('placeholder')}
aria-label={t('placeholder')}
onChange={(event) => setQuery(event.target.value)}
/>
<div className="search-palette__scope">
<label>
<input
type="checkbox"
checked={allPonds}
onChange={(event) => setAllPonds(event.target.checked)}
/>
{t('scope.all')}
</label>
{!allPonds && flat.length > 0 && (
<details className="search-palette__labels">
<summary>{t('labelFilter')}</summary>
<ul>
{flat.map((label) => (
<li key={label.id}>
<label>
<input
type="checkbox"
checked={labelIds.includes(label.id)}
onChange={(event) => toggleLabel(label.id, event.target.checked)}
/>
<span
className="label-chip__swatch"
style={{ backgroundColor: label.color }}
aria-hidden
/>
{label.name}
</label>
</li>
))}
</ul>
</details>
)}
</div>
{debounced.length === 0 ? (
recent.length > 0 ? (
<div className="search-palette__recent">
<p className="search-palette__hint">{t('recent')}</p>
<ul>
{recent.map((q) => (
<li key={q}>
<button type="button" className="linklike" onClick={() => setQuery(q)}>
{q}
</button>
</li>
))}
</ul>
</div>
) : (
<p className="search-palette__hint">{t('hint')}</p>
)
) : results.isError ? (
<p className="search-palette__hint">{t('error')}</p>
) : hits.length === 0 ? (
<p className="search-palette__hint">{results.isLoading ? '' : t('empty')}</p>
) : (
<ul className="search-results" role="listbox" aria-label={t('resultsLabel')}>
{hits.map((hit, index) => (
<li key={hit.pageId}>
<button
type="button"
role="option"
aria-selected={index === selected}
className={
index === selected ? 'search-result search-result--active' : 'search-result'
}
onMouseEnter={() => setSelected(index)}
onClick={() => open(hit)}
>
<span className="search-result__title">{hit.title}</span>
<span className="search-result__pond">{t('inPond', { pond: hit.pondName })}</span>
<LabelChips labelIds={hit.labelIds} byId={byId} />
<span className="search-result__snippet">
<HighlightedSnippet snippet={hit.snippet} />
</span>
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
import { SEARCH_HIGHLIGHT_END, SEARCH_HIGHLIGHT_START } from '@dorfteich/shared';
/**
* Renders a search snippet, wrapping the matched spans (delimited by the shared
* highlight sentinels, issue #49) in `<mark>`. The snippet is plain text, so
* splitting on the sentinels and rendering the pieces as React text is
* injection-safe no HTML from the content is ever interpreted.
*/
export function HighlightedSnippet({ snippet }: { snippet: string }): React.JSX.Element {
const parts: React.ReactNode[] = [];
let rest = snippet;
let key = 0;
while (rest.length > 0) {
const start = rest.indexOf(SEARCH_HIGHLIGHT_START);
if (start === -1) {
parts.push(rest);
break;
}
if (start > 0) parts.push(rest.slice(0, start));
const end = rest.indexOf(SEARCH_HIGHLIGHT_END, start + 1);
if (end === -1) {
// Unterminated marker — render the remainder verbatim.
parts.push(rest.slice(start + SEARCH_HIGHLIGHT_START.length));
break;
}
parts.push(<mark key={key++}>{rest.slice(start + SEARCH_HIGHLIGHT_START.length, end)}</mark>);
rest = rest.slice(end + SEARCH_HIGHLIGHT_END.length);
}
return <>{parts}</>;
}

View File

@ -1300,3 +1300,144 @@ button {
color: var(--color-text-muted);
font-size: 0.9rem;
}
/* Search palette (issue #50) -------------------------------------------- */
.topbar__search {
display: inline-flex;
align-items: center;
gap: var(--space-1);
border: 1px solid var(--color-border);
background: var(--color-bg-subtle);
border-radius: var(--radius);
padding: var(--space-1) var(--space-3);
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.9rem;
}
.topbar__search:hover {
background: var(--color-bg);
color: var(--color-text);
}
.search-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 10vh;
}
.search-backdrop {
position: absolute;
inset: 0;
background: rgb(0 0 0 / 35%);
}
.search-palette {
position: relative;
width: min(40rem, 92vw);
max-height: 75vh;
overflow-y: auto;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 12px 40px rgb(0 0 0 / 25%);
padding: var(--space-3);
}
.search-palette__input {
width: 100%;
font-size: 1.1rem;
padding: var(--space-2);
}
.search-palette__scope {
display: flex;
align-items: center;
gap: var(--space-4);
margin: var(--space-2) 0;
font-size: 0.9rem;
color: var(--color-text-muted);
}
.search-palette__labels ul {
list-style: none;
margin: var(--space-1) 0 0;
padding: 0;
max-height: 10rem;
overflow-y: auto;
}
.search-palette__labels label {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 2px 0;
}
.search-palette__hint {
color: var(--color-text-muted);
padding: var(--space-3);
text-align: center;
}
.search-palette__recent ul {
list-style: none;
margin: 0;
padding: 0;
}
.search-palette__recent li {
padding: var(--space-1) var(--space-2);
}
.search-results {
list-style: none;
margin: 0;
padding: 0;
}
.search-result {
display: block;
width: 100%;
text-align: left;
border: 0;
background: none;
border-radius: var(--radius);
padding: var(--space-2);
cursor: pointer;
color: var(--color-text);
}
.search-result--active,
.search-result:hover {
background: var(--color-bg-subtle);
}
.search-result__title {
font-weight: 600;
margin-right: var(--space-2);
}
.search-result__pond {
color: var(--color-text-muted);
font-size: 0.85rem;
}
.search-result__snippet {
display: block;
margin-top: 2px;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.search-result__snippet mark {
background: var(--color-accent);
color: var(--color-accent-contrast);
border-radius: 2px;
padding: 0 2px;
}

View File

@ -0,0 +1,17 @@
{
"open": "Suche",
"title": "Suche",
"placeholder": "Seiten durchsuchen…",
"scope": {
"pond": "Dieser Teich",
"all": "Alle meine Teiche"
},
"labelFilter": "Labels",
"empty": "Keine Ergebnisse gefunden.",
"hint": "Tippe, um deine Seiten zu durchsuchen.",
"error": "Suche fehlgeschlagen. Bitte erneut versuchen.",
"recent": "Letzte Suchen",
"resultsLabel": "Suchergebnisse",
"inPond": "in {{pond}}",
"close": "Schließen"
}

View File

@ -0,0 +1,17 @@
{
"open": "Search",
"title": "Search",
"placeholder": "Search pages…",
"scope": {
"pond": "This pond",
"all": "All my ponds"
},
"labelFilter": "Labels",
"empty": "No results found.",
"hint": "Type to search your pages.",
"error": "Search failed. Please try again.",
"recent": "Recent searches",
"resultsLabel": "Search results",
"inPond": "in {{pond}}",
"close": "Close"
}