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
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:
parent
91dfccf226
commit
19fb24c527
@ -204,6 +204,16 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/backlinks.spec.ts
|
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
|
- name: Dump server logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
|
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
|
||||||
|
|||||||
@ -106,6 +106,42 @@ describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
|
|||||||
expect(results).toEqual([]);
|
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 () => {
|
it('reindexAll rebuilds from the cache and is idempotent', async () => {
|
||||||
const before = await search.search({ q: term }, owner);
|
const before = await search.search({ q: term }, owner);
|
||||||
await search.reindexAll();
|
await search.reindexAll();
|
||||||
|
|||||||
65
apps/web/e2e/search.spec.ts
Normal file
65
apps/web/e2e/search.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
@ -4,6 +4,7 @@ import deEditor from '@dorfteich/shared/i18n/de/editor.json';
|
|||||||
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
||||||
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
|
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
|
||||||
import deLinks from '@dorfteich/shared/i18n/de/links.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 deSettings from '@dorfteich/shared/i18n/de/settings.json';
|
||||||
import enAuth from '@dorfteich/shared/i18n/en/auth.json';
|
import enAuth from '@dorfteich/shared/i18n/en/auth.json';
|
||||||
import enCommon from '@dorfteich/shared/i18n/en/common.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 enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
||||||
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
|
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
|
||||||
import enLinks from '@dorfteich/shared/i18n/en/links.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 enSettings from '@dorfteich/shared/i18n/en/settings.json';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
@ -35,6 +37,7 @@ void i18n
|
|||||||
editor: enEditor,
|
editor: enEditor,
|
||||||
labels: enLabels,
|
labels: enLabels,
|
||||||
links: enLinks,
|
links: enLinks,
|
||||||
|
search: enSearch,
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
common: deCommon,
|
common: deCommon,
|
||||||
@ -44,6 +47,7 @@ void i18n
|
|||||||
editor: deEditor,
|
editor: deEditor,
|
||||||
labels: deLabels,
|
labels: deLabels,
|
||||||
links: deLinks,
|
links: deLinks,
|
||||||
|
search: deSearch,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
|
import { SearchPalette } from '../search/SearchPalette';
|
||||||
import { PondSwitcher } from './PondSwitcher';
|
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 {
|
interface TopBarProps {
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
@ -15,6 +23,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
|||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
async function handleLogout(): Promise<void> {
|
async function handleLogout(): Promise<void> {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
@ -22,6 +31,19 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
|||||||
navigate('/login');
|
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 (
|
return (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<button
|
<button
|
||||||
@ -39,6 +61,17 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
|||||||
</Link>
|
</Link>
|
||||||
{user && <PondSwitcher />}
|
{user && <PondSwitcher />}
|
||||||
<span className="topbar__spacer" />
|
<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 ? (
|
{user ? (
|
||||||
<div className="user-menu">
|
<div className="user-menu">
|
||||||
<button
|
<button
|
||||||
|
|||||||
211
apps/web/src/search/SearchPalette.tsx
Normal file
211
apps/web/src/search/SearchPalette.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/web/src/search/highlight.tsx
Normal file
30
apps/web/src/search/highlight.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
@ -1300,3 +1300,144 @@ button {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.9rem;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
17
packages/shared/i18n/de/search.json
Normal file
17
packages/shared/i18n/de/search.json
Normal 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"
|
||||||
|
}
|
||||||
17
packages/shared/i18n/en/search.json
Normal file
17
packages/shared/i18n/en/search.json
Normal 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"
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user