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(null); const overlayRef = useRef(null); useModalFocus(overlayRef); const pond = useQuery({ queryKey: ['pond', pondSlug], queryFn: () => apiGet(`/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([]); const [selected, setSelected] = useState(0); const [recent, setRecent] = useState(() => 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(`/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 (
setQuery(event.target.value)} />
{!allPonds && flat.length > 0 && (
{t('labelFilter')}
    {flat.map((label) => (
  • ))}
)}
{debounced.length === 0 ? ( recent.length > 0 ? (

{t('recent')}

    {recent.map((q) => (
  • ))}
) : (

{t('hint')}

) ) : results.isError ? (

{t('error')}

) : hits.length === 0 ? (

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

) : (
    {hits.map((hit, index) => (
  • ))}
)}
); }