dorfteich/apps/web/src/search/SearchPalette.tsx
Claude Fable 5 521ea514b4
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m38s
CI / Build container images (pull_request) Successful in 4m14s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 1m6s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#211: classification through feeds, public API, search and the no-JS shell
Feeds: classified entries carry a standard Atom <category>
(term=level, scheme=urn:dorfteich:classification, label=the fixed
wording); the feed document states the highest contained level once;
all-open feeds carry none. Public API: page representations (list+get)
gain the classification field, OpenAPI + public-api.md documented.
Search: every hit carries the level and the palette renders the marking
with the snippet (compact form of the banner, text token only). No-JS
shell: banner above and below the content, own markup for the separate
render path; unclassified pages unchanged everywhere. One test per
channel (feed categories + count, public API list/get with the switch
on, search hit levels, shell top+bottom).

Also: fidelity CI sidecars get per-job container names — the fixed
names collided across parallel runs on the shared host (run 547's red
fidelity job; a fixed-name cleanup could even kill a sibling's live
sidecars).

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:23:53 +02:00

247 lines
8.9 KiB
TypeScript

import { classificationMarking } from '@dorfteich/shared';
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 { useModalFocus } from '../lib/use-modal-focus';
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 { t: tCommon } = useTranslation('common');
const navigate = useNavigate();
const { pondSlug } = useCurrentPondRoute();
const inputRef = useRef<HTMLInputElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
useModalFocus(overlayRef);
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')}
ref={overlayRef}
>
<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')}
<button
type="button"
className="linklike search-palette__recent-clear"
onClick={() => {
try {
localStorage.removeItem(RECENT_KEY);
} catch {
/* ignore availability errors */
}
setRecent([]);
}}
>
{t('recentClear')}
</button>
</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} role="presentation">
<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>
{/* A hit on a classified page is never shown unmarked
(issue #211, ADR 0022) — fixed wording, not localized. */}
{classificationMarking(hit.classification) && (
<span className="search-result__classification">
<span className="visually-hidden">{tCommon('classification.label')}: </span>
{classificationMarking(hit.classification)}
</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>
);
}