Phase 10: organization & polish — cross-doc search, tags, touch, warm failures
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
This commit is contained in:
145
web/src/components/DocList/SearchBox.tsx
Normal file
145
web/src/components/DocList/SearchBox.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user