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
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>
247 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|