Writer power-ups (Phase 11), plus the selection-bubble vs copy/paste fix. - Find & Replace (Ctrl/Cmd+F): SearchHighlight decoration extension + FindReplace bar (match-case, replace-all back-to-front, scroll without popping the selection bubble). - Read-aloud (Web Speech, offline) on the word card and selection bubble. - Keyboard/touch access to the ESL helpers: Ctrl/Cmd+D look up word at caret, Ctrl/Cmd+J rewrite selection, touch long-press lookup. Refactored the right-click handler into a shared openWordLookup(pos). - Whole-corpus backup: GET /api/docs/export-all zips every doc (md/docx), de-dupes filenames, dated name; sidebar download links. TestExportAll. - Smart typography input rules (curly quotes/em-dash/ellipsis), ASCII-only so CJK is untouched. - Duplicate doc, sidebar sort (Recent/Title/Longest), toolbar outline popover. - English phonetic (chosen over pinyin for an English learner): ECDICT-built phonetic.json.gz (46,579 words) + Result.Phonetic + WordCard IPA line; scripts/build_phonetic.py (full build + --seed fallback). - Selection bubble no longer blocks copy/paste: deferred to pointer-up and made click-through except on its buttons. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
155 lines
5.5 KiB
TypeScript
155 lines
5.5 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import { api, type DocSummary, type Tag, type 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
|
||
onDuplicate: (id: string) => void
|
||
onToggleTag: (docId: string, tag: Tag) => void
|
||
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||
}
|
||
|
||
// Sidebar sort orders. 'recent' keeps the server's updated_at-desc ordering.
|
||
type SortMode = 'recent' | 'title' | 'longest'
|
||
const SORTS: { value: SortMode; label: string }[] = [
|
||
{ value: 'recent', label: '最近 · Recent' },
|
||
{ value: 'title', label: '标题 · Title' },
|
||
{ value: 'longest', label: '字数 · Longest' },
|
||
]
|
||
|
||
// 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,
|
||
onDuplicate,
|
||
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 [sort, setSort] = useState<SortMode>('recent')
|
||
|
||
const filtered = useMemo(() => {
|
||
const base = activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs
|
||
if (sort === 'recent') return base // already updated_at-desc from the server
|
||
const arr = [...base]
|
||
if (sort === 'title') {
|
||
arr.sort((a, b) =>
|
||
(a.title || 'Untitled').localeCompare(b.title || 'Untitled', undefined, { sensitivity: 'base' }),
|
||
)
|
||
} else {
|
||
arr.sort((a, b) => b.word_count - a.word_count)
|
||
}
|
||
return arr
|
||
}, [docs, activeFilter, sort])
|
||
|
||
// 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-[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="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)')}
|
||
>
|
||
<span className="text-base leading-none">+</span> New Doc
|
||
</button>
|
||
|
||
{docs.length > 1 && (
|
||
<div className="flex items-center justify-end gap-1.5 px-1 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||
<span aria-hidden>↕</span>
|
||
<select
|
||
aria-label="Sort documents"
|
||
value={sort}
|
||
onChange={(e) => setSort(e.target.value as SortMode)}
|
||
className="bg-transparent text-xs font-semibold focus:outline-none"
|
||
style={{ color: 'var(--color-plum)' }}
|
||
>
|
||
{SORTS.map((s) => (
|
||
<option key={s.value} value={s.value}>
|
||
{s.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
|
||
{filtered.length === 0 ? (
|
||
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||
{activeFilter ? 'No documents with this tag.' : 'No documents yet.'}
|
||
</p>
|
||
) : (
|
||
filtered.map((doc) => (
|
||
<DocListItem
|
||
key={doc.id}
|
||
doc={doc}
|
||
active={doc.id === selectedId}
|
||
roster={roster}
|
||
onSelect={() => onSelect(doc.id)}
|
||
onDelete={() => onDelete(doc.id)}
|
||
onDuplicate={() => onDuplicate(doc.id)}
|
||
onToggleTag={onToggleTag}
|
||
onCreateTag={onCreateTag}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Whole-corpus backup — "download all my writing". Plain download links so
|
||
the browser saves the zip; no extra app state needed. */}
|
||
{docs.length > 0 && (
|
||
<div
|
||
className="flex items-center gap-2 px-1 pt-1 text-xs"
|
||
style={{ color: 'var(--color-muted)', borderTop: '1px solid var(--color-border)' }}
|
||
>
|
||
<span className="font-semibold">备份 · Back up all:</span>
|
||
<a href={api.exportAllUrl('docx')} download className="font-bold hover:underline" style={{ color: 'var(--color-accent-hover)' }}>
|
||
Word
|
||
</a>
|
||
<a href={api.exportAllUrl('md')} download className="font-bold hover:underline" style={{ color: 'var(--color-accent-hover)' }}>
|
||
Markdown
|
||
</a>
|
||
</div>
|
||
)}
|
||
</aside>
|
||
)
|
||
}
|