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,48 +1,101 @@
|
||||
import type { DocSummary } from '../../api/client'
|
||||
import { useState } from 'react'
|
||||
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
||||
import { TagChip } from './TagChip'
|
||||
import { TagPicker } from './TagPicker'
|
||||
|
||||
interface Props {
|
||||
doc: DocSummary
|
||||
active: boolean
|
||||
roster: Tag[]
|
||||
onSelect: () => void
|
||||
onDelete: () => void
|
||||
onToggleTag: (docId: string, tag: Tag) => void
|
||||
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||
}
|
||||
|
||||
// One row in the sidebar: title + word count, a delete affordance on hover, and
|
||||
// a rose wash when it's the open document.
|
||||
export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
|
||||
// One row in the sidebar: title + word count, its tag chips, a tag affordance and
|
||||
// a delete affordance on hover, and a rose wash when it's the open document.
|
||||
export function DocListItem({
|
||||
doc,
|
||||
active,
|
||||
roster,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onToggleTag,
|
||||
onCreateTag,
|
||||
}: Props) {
|
||||
const [picking, setPicking] = useState(false)
|
||||
const assignedIds = new Set(doc.tags.map((t) => t.id))
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className="group flex cursor-pointer items-center gap-2 px-3 py-2.5"
|
||||
className="group relative flex cursor-pointer flex-col gap-1 px-3 py-2.5"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: active ? 'var(--color-surface)' : 'transparent',
|
||||
boxShadow: active ? 'var(--shadow-soft)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-sm font-semibold"
|
||||
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-sm font-semibold"
|
||||
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
|
||||
>
|
||||
{doc.title || 'Untitled'}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag + delete affordances: always reachable on touch (no hover), fade in
|
||||
on hover for the pointer experience. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Tag document"
|
||||
title="Tags"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setPicking((v) => !v)
|
||||
}}
|
||||
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-sm"
|
||||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
{doc.title || 'Untitled'}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
|
||||
</div>
|
||||
🏷️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete document"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-base"
|
||||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
{picking && (
|
||||
<TagPicker
|
||||
roster={roster}
|
||||
assignedIds={assignedIds}
|
||||
onToggle={(t) => onToggleTag(doc.id, t)}
|
||||
onCreate={(name, color) => onCreateTag(doc.id, name, color)}
|
||||
onClose={() => setPicking(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete document"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center text-base opacity-0 group-hover:opacity-100"
|
||||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
{doc.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{doc.tags.map((t) => (
|
||||
<TagChip key={t.id} tag={t} onRemove={() => onToggleTag(doc.id, t)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user