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:
@@ -13,6 +13,11 @@ export function useCheckpoint(docId: string | null) {
|
||||
const [checking, setChecking] = useState(false)
|
||||
// True while a whole-document voice pass is in flight (explicit user action).
|
||||
const [voicing, setVoicing] = useState(false)
|
||||
// True when the last LLM pass couldn't reach the model (server 502 or network
|
||||
// error). Drives a gentle, reassuring "helper is resting" note — the writing
|
||||
// itself still saves fine, so this is awareness, not an error. Cleared on the
|
||||
// next success or doc switch.
|
||||
const [llmDown, setLlmDown] = useState(false)
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const docIdRef = useRef(docId)
|
||||
@@ -30,9 +35,11 @@ export function useCheckpoint(docId: string | null) {
|
||||
const fresh = await api.checkDoc(id)
|
||||
if (run === runRef.current && id === docIdRef.current) {
|
||||
setSuggestions(fresh)
|
||||
setLlmDown(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('checkpoint failed', err)
|
||||
if (run === runRef.current) setLlmDown(true)
|
||||
} finally {
|
||||
if (run === runRef.current) setChecking(false)
|
||||
}
|
||||
@@ -50,9 +57,11 @@ export function useCheckpoint(docId: string | null) {
|
||||
const full = await api.voiceDoc(id)
|
||||
if (run === runRef.current && id === docIdRef.current) {
|
||||
setSuggestions(full)
|
||||
setLlmDown(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('voice pass failed', err)
|
||||
if (run === runRef.current) setLlmDown(true)
|
||||
} finally {
|
||||
if (run === runRef.current) setVoicing(false)
|
||||
}
|
||||
@@ -72,6 +81,7 @@ export function useCheckpoint(docId: string | null) {
|
||||
setSuggestions([])
|
||||
setChecking(false)
|
||||
setVoicing(false)
|
||||
setLlmDown(false)
|
||||
if (!docId) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
@@ -94,5 +104,5 @@ export function useCheckpoint(docId: string | null) {
|
||||
setSuggestions((prev) => prev.filter((s) => s.id !== id))
|
||||
}, [])
|
||||
|
||||
return { suggestions, checking, voicing, schedule, runVoice, removeSuggestion }
|
||||
return { suggestions, checking, voicing, llmDown, schedule, runVoice, removeSuggestion }
|
||||
}
|
||||
|
||||
63
web/src/hooks/useTags.ts
Normal file
63
web/src/hooks/useTags.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, type Tag, type TagColor } from '../api/client'
|
||||
|
||||
// useTags owns the user's tag roster (the full set the writer has created, with
|
||||
// per-tag document counts). Document↔tag assignments live in App alongside the
|
||||
// document list; this hook is just the roster plus create/recolor/delete and a
|
||||
// refresh used to keep counts current after an assignment changes.
|
||||
export function useTags() {
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setTags(await api.listTags())
|
||||
} catch (err) {
|
||||
console.error('failed to load tags', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
// Create (or reuse) a tag, returning it. The roster is refreshed so the new
|
||||
// tag appears with a zero count.
|
||||
const createTag = useCallback(
|
||||
async (name: string, color: TagColor): Promise<Tag | null> => {
|
||||
try {
|
||||
const tag = await api.createTag(name, color)
|
||||
await refresh()
|
||||
return tag
|
||||
} catch (err) {
|
||||
console.error('failed to create tag', err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
)
|
||||
|
||||
const recolorTag = useCallback(
|
||||
async (id: string, color: TagColor) => {
|
||||
// Optimistic recolor; refresh reconciles.
|
||||
setTags((prev) => prev.map((t) => (t.id === id ? { ...t, color } : t)))
|
||||
try {
|
||||
await api.updateTag(id, { color })
|
||||
} catch (err) {
|
||||
console.error('failed to recolor tag', err)
|
||||
void refresh()
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
)
|
||||
|
||||
const deleteTag = useCallback(async (id: string) => {
|
||||
setTags((prev) => prev.filter((t) => t.id !== id))
|
||||
try {
|
||||
await api.deleteTag(id)
|
||||
} catch (err) {
|
||||
console.error('failed to delete tag', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { tags, refresh, createTag, recolorTag, deleteTag }
|
||||
}
|
||||
Reference in New Issue
Block a user