diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d276788..e88a1ac 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/apps/api/src/search/search.service.db.test.ts b/apps/api/src/search/search.service.db.test.ts index 71a3c9d..4678a69 100644 --- a/apps/api/src/search/search.service.db.test.ts +++ b/apps/api/src/search/search.service.db.test.ts @@ -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(); diff --git a/apps/web/e2e/search.spec.ts b/apps/web/e2e/search.spec.ts new file mode 100644 index 0000000..0a48f38 --- /dev/null +++ b/apps/web/e2e/search.spec.ts @@ -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>; + +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(); +}); diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 40d2bd6..e2d4eb7 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -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', diff --git a/apps/web/src/layout/TopBar.tsx b/apps/web/src/layout/TopBar.tsx index 5dd7094..b6a32e1 100644 --- a/apps/web/src/layout/TopBar.tsx +++ b/apps/web/src/layout/TopBar.tsx @@ -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 { 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 (
+ )} + {searchOpen && user && setSearchOpen(false)} />} {user ? (
+ + ))} + +
+ ) : ( +

{t('hint')}

+ ) + ) : results.isError ? ( +

{t('error')}

+ ) : hits.length === 0 ? ( +

{results.isLoading ? '' : t('empty')}

+ ) : ( +
    + {hits.map((hit, index) => ( +
  • + +
  • + ))} +
+ )} + + + ); +} diff --git a/apps/web/src/search/highlight.tsx b/apps/web/src/search/highlight.tsx new file mode 100644 index 0000000..6bb3fb6 --- /dev/null +++ b/apps/web/src/search/highlight.tsx @@ -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 ``. 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({rest.slice(start + SEARCH_HIGHLIGHT_START.length, end)}); + rest = rest.slice(end + SEARCH_HIGHLIGHT_END.length); + } + return <>{parts}; +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index e446676..bbaa082 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -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; +} diff --git a/packages/shared/i18n/de/search.json b/packages/shared/i18n/de/search.json new file mode 100644 index 0000000..9587c5f --- /dev/null +++ b/packages/shared/i18n/de/search.json @@ -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" +} diff --git a/packages/shared/i18n/en/search.json b/packages/shared/i18n/en/search.json new file mode 100644 index 0000000..b2bf468 --- /dev/null +++ b/packages/shared/i18n/en/search.json @@ -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" +}