Files
petal/web/src/components/DocList/DocList.tsx
prosolis db737fa612 Editor: Find & Replace, read-aloud, backup, typography, phonetic, org niceties
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
2026-06-26 09:47:26 -07:00

155 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}