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 \
|
||||
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
|
||||
|
||||
@ -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();
|
||||
|
||||
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 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',
|
||||
|
||||
@ -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
|
||||
|
||||
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);
|
||||
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