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:
@@ -1,26 +1,71 @@
|
||||
import type { DocSummary } from '../../api/client'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
||||
import { DocListItem } from './DocListItem'
|
||||
import { SearchBox } from './SearchBox'
|
||||
import { TagChip } from './TagChip'
|
||||
|
||||
interface Props {
|
||||
docs: DocSummary[]
|
||||
roster: Tag[]
|
||||
selectedId: string | null
|
||||
onSelect: (id: string) => void
|
||||
onCreate: () => void
|
||||
onDelete: (id: string) => void
|
||||
onToggleTag: (docId: string, tag: Tag) => void
|
||||
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||
}
|
||||
|
||||
// DocList is the 260px sidebar: the document browser plus a New button.
|
||||
export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Props) {
|
||||
// DocList is the sidebar: a cross-document search box, a tag filter bar, the New
|
||||
// button, and the document browser (each row showing its tag chips).
|
||||
export function DocList({
|
||||
docs,
|
||||
roster,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onToggleTag,
|
||||
onCreateTag,
|
||||
}: Props) {
|
||||
// Active tag filter (null = show all). Cleared automatically if the tag
|
||||
// disappears from the roster.
|
||||
const [filterId, setFilterId] = useState<string | null>(null)
|
||||
const activeFilter = filterId && roster.some((t) => t.id === filterId) ? filterId : null
|
||||
|
||||
const filtered = useMemo(
|
||||
() => (activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs),
|
||||
[docs, activeFilter],
|
||||
)
|
||||
|
||||
// Only surface tags that are actually in use, so the filter bar stays tidy.
|
||||
const usedTags = useMemo(() => roster.filter((t) => (t.doc_count ?? 0) > 0), [roster])
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex h-full w-[260px] flex-col gap-2 p-3"
|
||||
className="flex h-full w-[280px] flex-col gap-2 p-3"
|
||||
style={{ borderRight: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<SearchBox onSelect={onSelect} />
|
||||
|
||||
{usedTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pb-0.5">
|
||||
{usedTags.map((t) => (
|
||||
<TagChip
|
||||
key={t.id}
|
||||
tag={t}
|
||||
count={t.doc_count}
|
||||
active={activeFilter === t.id}
|
||||
onClick={() => setFilterId((cur) => (cur === t.id ? null : t.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
className="flex items-center justify-center gap-2 py-2.5 text-sm font-bold text-white"
|
||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
|
||||
className="petal-tap flex items-center justify-center gap-2 text-sm font-bold text-white"
|
||||
style={{ minHeight: 44, borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
@@ -28,18 +73,21 @@ export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Prop
|
||||
</button>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||
{docs.length === 0 ? (
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
No documents yet.
|
||||
{activeFilter ? 'No documents with this tag.' : 'No documents yet.'}
|
||||
</p>
|
||||
) : (
|
||||
docs.map((doc) => (
|
||||
filtered.map((doc) => (
|
||||
<DocListItem
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
active={doc.id === selectedId}
|
||||
roster={roster}
|
||||
onSelect={() => onSelect(doc.id)}
|
||||
onDelete={() => onDelete(doc.id)}
|
||||
onToggleTag={onToggleTag}
|
||||
onCreateTag={onCreateTag}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user