Cross-document FTS5 search (trigram tokenizer for EN + space-free CJK, kept in sync by triggers, back-filled from existing docs). GET /api/search uses the FTS index for queries >=3 runes and a LIKE fallback for 1-2 (so 2-char Chinese words resolve); snippets are built in Go with rune-aware boundaries and sentinel highlights. Tags: user-scoped tags + document_tags join (both cascade), idempotent create/assign, per-tag doc counts. Doc list and search carry each doc's tags (one tagsByDoc query). Frontend: useTags, TagChip/TagPicker/SearchBox, rewritten DocList with chips + filter bar + search. Tablet/touch: responsive sidebar drawer (hamburger + scrim <768px), coarse- pointer tap targets, tap-to-open + outside-pointerdown-close for suggestion cards. Warm LLM-down state: useCheckpoint llmDown flag drives a gentle bilingual StatusBar note (writing still saves locally). Migration 0004 (tags + FTS). Tests: tags lifecycle, search EN/CJK/LIKE/update- reindex. go build/vet/test, tsc, vite all clean; verified live on deployment host. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
146 lines
4.9 KiB
TypeScript
146 lines
4.9 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { api, splitSnippet, type SearchResult } from '../../api/client'
|
||
|
||
interface Props {
|
||
// Called when a result is chosen — opens that document.
|
||
onSelect: (id: string) => void
|
||
}
|
||
|
||
const DEBOUNCE_MS = 220
|
||
|
||
// SearchBox is the cross-document finder at the top of the sidebar. Typing
|
||
// debounces a full-text query; results drop in below with a highlighted snippet.
|
||
// Clearing (× or empty) returns the sidebar to the normal document list.
|
||
export function SearchBox({ onSelect }: Props) {
|
||
const [q, setQ] = useState('')
|
||
const [results, setResults] = useState<SearchResult[] | null>(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const runRef = useRef(0)
|
||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||
|
||
useEffect(() => {
|
||
clearTimeout(debounceRef.current)
|
||
const term = q.trim()
|
||
if (!term) {
|
||
setResults(null)
|
||
setBusy(false)
|
||
return
|
||
}
|
||
setBusy(true)
|
||
const run = ++runRef.current
|
||
debounceRef.current = setTimeout(async () => {
|
||
try {
|
||
const hits = await api.search(term)
|
||
if (run === runRef.current) setResults(hits)
|
||
} catch (err) {
|
||
console.error('search failed', err)
|
||
if (run === runRef.current) setResults([])
|
||
} finally {
|
||
if (run === runRef.current) setBusy(false)
|
||
}
|
||
}, DEBOUNCE_MS)
|
||
return () => clearTimeout(debounceRef.current)
|
||
}, [q])
|
||
|
||
const clear = () => {
|
||
setQ('')
|
||
setResults(null)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
<div className="relative">
|
||
<span
|
||
aria-hidden
|
||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm"
|
||
style={{ color: 'var(--color-muted)' }}
|
||
>
|
||
🔍
|
||
</span>
|
||
<input
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
placeholder="搜索 · Search"
|
||
aria-label="Search documents"
|
||
className="petal-tap w-full bg-transparent pl-9 pr-8 text-sm focus:outline-none"
|
||
style={{
|
||
height: 40,
|
||
borderRadius: 'var(--radius-pill)',
|
||
border: '1px solid var(--color-border)',
|
||
background: 'var(--color-surface)',
|
||
color: 'var(--color-plum)',
|
||
}}
|
||
/>
|
||
{q && (
|
||
<button
|
||
type="button"
|
||
aria-label="Clear search"
|
||
onClick={clear}
|
||
className="absolute right-2 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center"
|
||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{results !== null && (
|
||
<div className="petal-search-results flex flex-col gap-0.5">
|
||
{busy && results.length === 0 ? (
|
||
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||
查找中… · Searching…
|
||
</p>
|
||
) : results.length === 0 ? (
|
||
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||
没有找到 · No matches
|
||
</p>
|
||
) : (
|
||
results.map((r) => (
|
||
<button
|
||
key={r.id}
|
||
type="button"
|
||
onClick={() => onSelect(r.id)}
|
||
className="petal-tap flex flex-col items-start gap-0.5 px-3 py-2 text-left"
|
||
style={{ borderRadius: 'var(--radius-card)' }}
|
||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||
>
|
||
<span
|
||
className="w-full truncate text-sm font-bold"
|
||
style={{ color: 'var(--color-plum)' }}
|
||
>
|
||
{r.title || 'Untitled'}
|
||
</span>
|
||
{r.snippet && (
|
||
<span
|
||
className="line-clamp-2 text-xs"
|
||
style={{ color: 'var(--color-muted)', lineHeight: 1.4 }}
|
||
>
|
||
{splitSnippet(r.snippet).map((seg, i) =>
|
||
seg.hit ? (
|
||
<mark
|
||
key={i}
|
||
style={{
|
||
background: 'color-mix(in srgb, var(--color-accent) 35%, white)',
|
||
color: 'var(--color-plum)',
|
||
borderRadius: 3,
|
||
padding: '0 1px',
|
||
}}
|
||
>
|
||
{seg.text}
|
||
</mark>
|
||
) : (
|
||
<span key={i}>{seg.text}</span>
|
||
),
|
||
)}
|
||
</span>
|
||
)}
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|