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,8 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, type DocSummary, type Document, type Suggestion } from './api/client'
|
||||
import { api, type DocSummary, type Document, type Suggestion, type Tag, type TagColor } from './api/client'
|
||||
import { useAutoSave } from './hooks/useAutoSave'
|
||||
import { useCheckpoint } from './hooks/useCheckpoint'
|
||||
import { useSpellChecker } from './hooks/useSpellChecker'
|
||||
import { useTags } from './hooks/useTags'
|
||||
import { DocList } from './components/DocList/DocList'
|
||||
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||
import { ToneSelect } from './components/Editor/ToneSelect'
|
||||
@@ -27,6 +28,9 @@ export default function App() {
|
||||
// Distraction-free mode: entered on editor focus, collapses the doc-list
|
||||
// sidebar. Escape or a click outside the editor canvas restores it.
|
||||
const [focusMode, setFocusMode] = useState(false)
|
||||
// Mobile drawer: below the tablet breakpoint the sidebar is an overlay toggled
|
||||
// by the header hamburger. Ignored on wide screens (sidebar is always in-flow).
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
// Monotonic counters the companion watches to react to writing + accepts.
|
||||
const [editTick, setEditTick] = useState(0)
|
||||
@@ -59,20 +63,67 @@ export default function App() {
|
||||
suggestions,
|
||||
checking,
|
||||
voicing,
|
||||
llmDown,
|
||||
schedule: scheduleCheckpoint,
|
||||
runVoice,
|
||||
removeSuggestion,
|
||||
} = useCheckpoint(currentDoc?.id ?? null)
|
||||
// Browser-side spell checker — loads the en-US dictionary once per session.
|
||||
const { checker: spellChecker, addWord } = useSpellChecker()
|
||||
// The tag roster (with counts). Assignments live on the doc summaries below.
|
||||
const { tags: tagRoster, refresh: refreshTags, createTag } = useTags()
|
||||
|
||||
// Patch a summary in the sidebar list (optimistic title / word-count updates).
|
||||
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
|
||||
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)))
|
||||
}, [])
|
||||
|
||||
// Attach or detach a tag on a document, updating the sidebar optimistically and
|
||||
// refreshing the roster so its counts stay current. Tags are kept sorted by
|
||||
// name to match the server's ordering.
|
||||
const setDocTag = useCallback(
|
||||
async (docId: string, tag: Tag, attach: boolean) => {
|
||||
setDocs((prev) =>
|
||||
prev.map((d) => {
|
||||
if (d.id !== docId) return d
|
||||
const without = d.tags.filter((t) => t.id !== tag.id)
|
||||
const next = attach ? [...without, tag] : without
|
||||
next.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
||||
return { ...d, tags: next }
|
||||
}),
|
||||
)
|
||||
try {
|
||||
if (attach) await api.assignTag(docId, tag.id)
|
||||
else await api.unassignTag(docId, tag.id)
|
||||
void refreshTags()
|
||||
} catch (err) {
|
||||
console.error('tag assignment failed', err)
|
||||
void refreshTags()
|
||||
}
|
||||
},
|
||||
[refreshTags],
|
||||
)
|
||||
|
||||
const handleToggleTag = useCallback(
|
||||
(docId: string, tag: Tag) => {
|
||||
const doc = docs.find((d) => d.id === docId)
|
||||
const has = doc?.tags.some((t) => t.id === tag.id) ?? false
|
||||
void setDocTag(docId, tag, !has)
|
||||
},
|
||||
[docs, setDocTag],
|
||||
)
|
||||
|
||||
const handleCreateTag = useCallback(
|
||||
async (docId: string, name: string, color: TagColor) => {
|
||||
const tag = await createTag(name, color)
|
||||
if (tag) void setDocTag(docId, tag, true)
|
||||
},
|
||||
[createTag, setDocTag],
|
||||
)
|
||||
|
||||
const openDoc = useCallback(
|
||||
async (id: string) => {
|
||||
setDrawerOpen(false) // close the mobile drawer when a doc is chosen
|
||||
// Decide the leaving doc's fate before any await mutates state.
|
||||
const leaving = currentDocRef.current
|
||||
const leavingBlank = isBlankDraft()
|
||||
@@ -103,7 +154,7 @@ export default function App() {
|
||||
let list = await api.listDocs()
|
||||
if (list.length === 0) {
|
||||
const fresh = await api.createDoc()
|
||||
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at }]
|
||||
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] }]
|
||||
setDocs(list)
|
||||
setCurrentDoc(fresh)
|
||||
setTitle(fresh.title)
|
||||
@@ -129,7 +180,7 @@ export default function App() {
|
||||
await saveNow()
|
||||
const fresh = await api.createDoc()
|
||||
setDocs((prev) => [
|
||||
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at },
|
||||
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] },
|
||||
...prev,
|
||||
])
|
||||
setCurrentDoc(fresh)
|
||||
@@ -265,23 +316,44 @@ export default function App() {
|
||||
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"
|
||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle document list"
|
||||
onClick={() => {
|
||||
setFocusMode(false)
|
||||
setDrawerOpen((v) => !v)
|
||||
}}
|
||||
className="petal-sidebar-toggle petal-tap-sm -ml-2 mr-1 items-center justify-center text-xl"
|
||||
style={{ borderRadius: 'var(--radius-pill)', width: 36, height: 36, color: 'var(--color-plum)' }}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<span className="text-xl">🌸</span>
|
||||
<span className="text-lg font-extrabold text-plum">Petal</span>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div
|
||||
className={`petal-no-print petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}`}
|
||||
className={`petal-no-print petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}${drawerOpen ? ' petal-drawer-open' : ''}`}
|
||||
>
|
||||
<DocList
|
||||
docs={docs}
|
||||
roster={tagRoster}
|
||||
selectedId={currentDoc?.id ?? null}
|
||||
onSelect={openDoc}
|
||||
onCreate={handleCreate}
|
||||
onDelete={handleDelete}
|
||||
onToggleTag={handleToggleTag}
|
||||
onCreateTag={handleCreateTag}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`petal-no-print petal-scrim${drawerOpen ? ' petal-scrim-show' : ''}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
{currentDoc ? (
|
||||
<>
|
||||
@@ -346,6 +418,7 @@ export default function App() {
|
||||
saveStatus={status}
|
||||
checking={checking}
|
||||
voicing={voicing}
|
||||
llmDown={llmDown}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
// Thin fetch wrappers for the Petal backend. Everything lives under /api, which
|
||||
// Vite proxies to the Go server in dev and the binary serves directly in prod.
|
||||
|
||||
// A tag's palette key, mapped to a CSS color on the frontend. Mirrors the
|
||||
// backend TagColor* constants; unknown values render as rose.
|
||||
export type TagColor = 'rose' | 'mint' | 'peach' | 'lavender' | 'sky' | 'honey'
|
||||
|
||||
export interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
color: TagColor
|
||||
doc_count?: number // present only in the tag-roster listing
|
||||
}
|
||||
|
||||
export interface DocSummary {
|
||||
id: string
|
||||
title: string
|
||||
word_count: number
|
||||
updated_at: string
|
||||
tags: Tag[]
|
||||
}
|
||||
|
||||
// One cross-document search hit. `snippet` is plain text with the matched span
|
||||
// wrapped in the … sentinels (see splitSnippet) for highlighting.
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
title: string
|
||||
word_count: number
|
||||
updated_at: string
|
||||
snippet: string
|
||||
tags: Tag[]
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
@@ -155,11 +178,78 @@ export const api = {
|
||||
body: JSON.stringify({ text, style }),
|
||||
}),
|
||||
|
||||
// Cross-document full-text search (title + body). Returns hits with a
|
||||
// highlighted snippet. Empty query returns []. Encodes the term for the URL.
|
||||
search: (q: string) => req<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`),
|
||||
|
||||
// Tags. listTags returns the roster with per-tag document counts; createTag is
|
||||
// idempotent on name (returns the existing tag if it already exists);
|
||||
// updateTag recolors/renames; deleteTag removes it (assignments cascade).
|
||||
// assignTag/unassignTag link a tag to one document.
|
||||
listTags: () => req<Tag[]>('/tags'),
|
||||
createTag: (name: string, color: TagColor) =>
|
||||
req<Tag>('/tags', { method: 'POST', body: JSON.stringify({ name, color }) }),
|
||||
updateTag: (id: string, patch: { name?: string; color?: TagColor }) =>
|
||||
req<Tag>(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
deleteTag: (id: string) => req<void>(`/tags/${id}`, { method: 'DELETE' }),
|
||||
assignTag: (docId: string, tagId: string) =>
|
||||
req<void>(`/docs/${docId}/tags`, { method: 'POST', body: JSON.stringify({ tag_id: tagId }) }),
|
||||
unassignTag: (docId: string, tagId: string) =>
|
||||
req<void>(`/docs/${docId}/tags/${tagId}`, { method: 'DELETE' }),
|
||||
|
||||
// Current deployed build id — changes whenever a new frontend ships. The
|
||||
// app polls this to offer a refresh. Bypasses any cache so the answer is live.
|
||||
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
|
||||
}
|
||||
|
||||
// The sentinel characters the server wraps a search match in (\x01 … \x02). Used
|
||||
// by splitSnippet to render the matched span highlighted.
|
||||
export const SNIPPET_HL_START = String.fromCharCode(1)
|
||||
export const SNIPPET_HL_END = String.fromCharCode(2)
|
||||
|
||||
// splitSnippet breaks a server snippet into ordered { text, hit } segments so the
|
||||
// UI can bold the matched span(s) without dangerously setting innerHTML.
|
||||
export function splitSnippet(snippet: string): { text: string; hit: boolean }[] {
|
||||
const out: { text: string; hit: boolean }[] = []
|
||||
let rest = snippet
|
||||
for (;;) {
|
||||
const start = rest.indexOf(SNIPPET_HL_START)
|
||||
if (start < 0) {
|
||||
if (rest) out.push({ text: rest, hit: false })
|
||||
break
|
||||
}
|
||||
if (start > 0) out.push({ text: rest.slice(0, start), hit: false })
|
||||
const end = rest.indexOf(SNIPPET_HL_END, start + 1)
|
||||
if (end < 0) {
|
||||
// Unterminated (shouldn't happen) — emit the remainder plainly.
|
||||
out.push({ text: rest.slice(start + 1), hit: false })
|
||||
break
|
||||
}
|
||||
out.push({ text: rest.slice(start + 1, end), hit: true })
|
||||
rest = rest.slice(end + 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tagColorVar maps a tag color key to its CSS custom property. Unknown keys fall
|
||||
// back to the rose accent, matching the backend's coercion.
|
||||
export function tagColorVar(color: TagColor | string): string {
|
||||
switch (color) {
|
||||
case 'mint':
|
||||
return 'var(--color-mint)'
|
||||
case 'peach':
|
||||
return 'var(--color-peach)'
|
||||
case 'lavender':
|
||||
return 'var(--color-lavender)'
|
||||
case 'sky':
|
||||
return 'var(--color-sky)'
|
||||
case 'honey':
|
||||
return 'var(--color-honey)'
|
||||
default:
|
||||
return 'var(--color-accent)'
|
||||
}
|
||||
}
|
||||
|
||||
// One turn in an Ask Petal conversation. History lives only in the component —
|
||||
// the server is stateless and re-injects the suggestion context every request.
|
||||
export interface ChatMessage {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
145
web/src/components/DocList/SearchBox.tsx
Normal file
145
web/src/components/DocList/SearchBox.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, splitSnippet, type SearchResult } from '../../api/client'
|
||||
|
||||
interface Props {
|
||||
// Called when a result is chosen — opens that document.
|
||||
onSelect: (id: string) => void
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 220
|
||||
|
||||
// SearchBox is the cross-document finder at the top of the sidebar. Typing
|
||||
// debounces a full-text query; results drop in below with a highlighted snippet.
|
||||
// Clearing (× or empty) returns the sidebar to the normal document list.
|
||||
export function SearchBox({ onSelect }: Props) {
|
||||
const [q, setQ] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[] | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const runRef = useRef(0)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(debounceRef.current)
|
||||
const term = q.trim()
|
||||
if (!term) {
|
||||
setResults(null)
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
const run = ++runRef.current
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const hits = await api.search(term)
|
||||
if (run === runRef.current) setResults(hits)
|
||||
} catch (err) {
|
||||
console.error('search failed', err)
|
||||
if (run === runRef.current) setResults([])
|
||||
} finally {
|
||||
if (run === runRef.current) setBusy(false)
|
||||
}
|
||||
}, DEBOUNCE_MS)
|
||||
return () => clearTimeout(debounceRef.current)
|
||||
}, [q])
|
||||
|
||||
const clear = () => {
|
||||
setQ('')
|
||||
setResults(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative">
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
>
|
||||
🔍
|
||||
</span>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="搜索 · Search"
|
||||
aria-label="Search documents"
|
||||
className="petal-tap w-full bg-transparent pl-9 pr-8 text-sm focus:outline-none"
|
||||
style={{
|
||||
height: 40,
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-plum)',
|
||||
}}
|
||||
/>
|
||||
{q && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
onClick={clear}
|
||||
className="absolute right-2 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center"
|
||||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{results !== null && (
|
||||
<div className="petal-search-results flex flex-col gap-0.5">
|
||||
{busy && results.length === 0 ? (
|
||||
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
查找中… · Searching…
|
||||
</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
没有找到 · No matches
|
||||
</p>
|
||||
) : (
|
||||
results.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(r.id)}
|
||||
className="petal-tap flex flex-col items-start gap-0.5 px-3 py-2 text-left"
|
||||
style={{ borderRadius: 'var(--radius-card)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span
|
||||
className="w-full truncate text-sm font-bold"
|
||||
style={{ color: 'var(--color-plum)' }}
|
||||
>
|
||||
{r.title || 'Untitled'}
|
||||
</span>
|
||||
{r.snippet && (
|
||||
<span
|
||||
className="line-clamp-2 text-xs"
|
||||
style={{ color: 'var(--color-muted)', lineHeight: 1.4 }}
|
||||
>
|
||||
{splitSnippet(r.snippet).map((seg, i) =>
|
||||
seg.hit ? (
|
||||
<mark
|
||||
key={i}
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-accent) 35%, white)',
|
||||
color: 'var(--color-plum)',
|
||||
borderRadius: 3,
|
||||
padding: '0 1px',
|
||||
}}
|
||||
>
|
||||
{seg.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{seg.text}</span>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
web/src/components/DocList/TagChip.tsx
Normal file
75
web/src/components/DocList/TagChip.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { tagColorVar, type Tag } from '../../api/client'
|
||||
|
||||
interface Props {
|
||||
tag: Tag
|
||||
// When set, renders an interactive chip (filter toggle); `active` fills it.
|
||||
onClick?: () => void
|
||||
active?: boolean
|
||||
// When set, shows a small × to remove the tag (used on document rows).
|
||||
onRemove?: () => void
|
||||
// Optional trailing count (used in the filter bar).
|
||||
count?: number
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
// TagChip is the pill used everywhere a tag appears: on document rows, in the
|
||||
// filter bar, and in the assign popover. The palette color tints a soft wash; a
|
||||
// filled variant marks an active filter. Keeps tap targets comfortable on touch.
|
||||
export function TagChip({ tag, onClick, active, onRemove, count, size = 'sm' }: Props) {
|
||||
const color = tagColorVar(tag.color)
|
||||
const interactive = !!onClick
|
||||
const pad = size === 'md' ? '0.3rem 0.7rem' : '0.12rem 0.5rem'
|
||||
const font = size === 'md' ? '0.8rem' : '0.7rem'
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={
|
||||
onClick
|
||||
? (e) => {
|
||||
e.stopPropagation()
|
||||
onClick()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`petal-tag-chip inline-flex items-center gap-1 whitespace-nowrap font-bold${interactive ? ' cursor-pointer' : ''}`}
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
padding: pad,
|
||||
fontSize: font,
|
||||
lineHeight: 1.2,
|
||||
background: active ? color : 'color-mix(in srgb, ' + color + ' 22%, white)',
|
||||
color: active ? 'white' : 'var(--color-plum)',
|
||||
border: `1px solid ${active ? color : 'color-mix(in srgb, ' + color + ' 45%, white)'}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block shrink-0"
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: active ? 'white' : color,
|
||||
}}
|
||||
/>
|
||||
{tag.name}
|
||||
{typeof count === 'number' && (
|
||||
<span style={{ opacity: 0.7, fontWeight: 700 }}>{count}</span>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${tag.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove()
|
||||
}}
|
||||
className="ml-0.5 inline-flex items-center justify-center"
|
||||
style={{ color: 'inherit', opacity: 0.65, fontSize: '0.85em', lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
139
web/src/components/DocList/TagPicker.tsx
Normal file
139
web/src/components/DocList/TagPicker.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { tagColorVar, type Tag, type TagColor } from '../../api/client'
|
||||
|
||||
const COLORS: TagColor[] = ['rose', 'mint', 'peach', 'lavender', 'sky', 'honey']
|
||||
|
||||
interface Props {
|
||||
roster: Tag[]
|
||||
assignedIds: Set<string>
|
||||
onToggle: (tag: Tag) => void
|
||||
onCreate: (name: string, color: TagColor) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// TagPicker is the small popover for managing one document's tags: tap an
|
||||
// existing tag to attach/detach it, or type a new name (with a color swatch) to
|
||||
// create-and-attach. Closes on outside click or Escape.
|
||||
export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }: Props) {
|
||||
const [name, setName] = useState('')
|
||||
const [color, setColor] = useState<TagColor>('rose')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose()
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
// Defer so the opening click doesn't immediately close it.
|
||||
const id = setTimeout(() => document.addEventListener('pointerdown', onDown), 0)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
clearTimeout(id)
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const submit = () => {
|
||||
const n = name.trim()
|
||||
if (!n) return
|
||||
onCreate(n, color)
|
||||
setName('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="petal-tag-picker absolute z-20 flex w-60 flex-col gap-2 p-3"
|
||||
style={{
|
||||
top: 'calc(100% + 4px)',
|
||||
right: 0,
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: 'var(--color-surface)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
标签 · Tags
|
||||
</div>
|
||||
|
||||
{roster.length > 0 && (
|
||||
<div className="flex max-h-40 flex-wrap gap-1.5 overflow-y-auto">
|
||||
{roster.map((t) => {
|
||||
const on = assignedIds.has(t.id)
|
||||
const c = tagColorVar(t.color)
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => onToggle(t)}
|
||||
className="petal-tap inline-flex items-center gap-1 whitespace-nowrap text-xs font-bold"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
padding: '0.25rem 0.6rem',
|
||||
background: on ? c : 'color-mix(in srgb, ' + c + ' 18%, white)',
|
||||
color: on ? 'white' : 'var(--color-plum)',
|
||||
border: `1px solid ${on ? c : 'color-mix(in srgb, ' + c + ' 40%, white)'}`,
|
||||
}}
|
||||
>
|
||||
{on && <span aria-hidden>✓</span>}
|
||||
{t.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5 pt-1" style={{ borderTop: '1px solid var(--color-border)' }}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
aria-label={`Color ${c}`}
|
||||
onClick={() => setColor(c)}
|
||||
className="petal-tap-sm"
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: tagColorVar(c),
|
||||
border: color === c ? '2px solid var(--color-plum)' : '2px solid transparent',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
}}
|
||||
placeholder="新标签 · New tag"
|
||||
aria-label="New tag name"
|
||||
className="petal-tap min-w-0 flex-1 bg-transparent px-2.5 text-sm focus:outline-none"
|
||||
style={{
|
||||
height: 34,
|
||||
borderRadius: 'var(--radius-input)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-plum)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="petal-tap-sm shrink-0 px-3 text-sm font-bold text-white"
|
||||
style={{ height: 34, borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -362,8 +362,17 @@ export function EditorCore({
|
||||
// Click a red-underlined word to open its spelling popover, anchored under the
|
||||
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same
|
||||
// misspelling appearing elsewhere); nspell supplies the corrections.
|
||||
//
|
||||
// Tapping an AI-suggestion highlight also opens its card here — on touch there's
|
||||
// no hover, so the tap is the only way in (mouse users still get hover).
|
||||
const handleSpellClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
|
||||
if (suggestionEl) {
|
||||
const id = suggestionEl.getAttribute('data-suggestion-id')
|
||||
if (id) openCardFor(id, suggestionEl)
|
||||
return
|
||||
}
|
||||
if (!editor || !spellChecker) return
|
||||
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
|
||||
if (!target) return
|
||||
@@ -383,7 +392,7 @@ export function EditorCore({
|
||||
setWordInfo(null)
|
||||
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||
},
|
||||
[editor, spellChecker, closeCard],
|
||||
[editor, spellChecker, closeCard, openCardFor],
|
||||
)
|
||||
|
||||
const replaceMisspelling = useCallback(
|
||||
@@ -634,6 +643,20 @@ export function EditorCore({
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [pinned, closeCard])
|
||||
|
||||
// Touch has no hover, so the card is opened by a tap and can't close itself on
|
||||
// mouse-leave. Whenever a card is open, a pointer-down outside both the card and
|
||||
// any highlight dismisses it. Excluding `.petal-suggestion` lets a tap on
|
||||
// another highlight reopen for that one (via the click handler) without flicker.
|
||||
useEffect(() => {
|
||||
if (!hover) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
const t = e.target as HTMLElement
|
||||
if (!t.closest('.petal-suggestion-card') && !t.closest('.petal-suggestion')) closeCard()
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
return () => document.removeEventListener('pointerdown', onDown)
|
||||
}, [hover, closeCard])
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} />
|
||||
|
||||
@@ -11,6 +11,9 @@ interface Props {
|
||||
checking: boolean
|
||||
// True while a whole-document voice pass runs — shows a breathing honey dot.
|
||||
voicing: boolean
|
||||
// True when Petal can't reach its LLM helper — shows a gentle, reassuring note
|
||||
// (the writing still saves locally, so this is awareness, not an error).
|
||||
llmDown: boolean
|
||||
}
|
||||
|
||||
const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||
@@ -24,7 +27,7 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||
// StatusBar is the slim footer: word count on the left, save state and the
|
||||
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
||||
// circle that breathes while a check is in flight (spec → Signature animations).
|
||||
export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Props) {
|
||||
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmDown }: Props) {
|
||||
const label = SAVE_LABEL[saveStatus]
|
||||
// The expanded stats panel toggles open when the word count is clicked.
|
||||
const [statsOpen, setStatsOpen] = useState(false)
|
||||
@@ -86,6 +89,20 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Pr
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{llmDown && !checking && !voicing && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
title="Petal can't reach its writing helper right now — your text is still saved."
|
||||
style={{ color: 'var(--color-honey)' }}
|
||||
>
|
||||
<span aria-hidden>🌙</span>
|
||||
<span>小助手在休息</span>
|
||||
<span style={{ opacity: 0.75 }}>· Petal's helper is resting · 文字已保存</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{label && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -282,6 +282,102 @@ button, a, input {
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* --- Organization & search (Phase 10) --------------------------------------
|
||||
Tag chips, the search results list, and per-row affordances. The row actions
|
||||
(tag / delete) fade in on hover for pointer users but are always visible on
|
||||
touch (no hover) — see the coarse-pointer block below. */
|
||||
.petal-tag-chip {
|
||||
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
|
||||
}
|
||||
.petal-search-results {
|
||||
animation: petal-suggestion-in 160ms ease both;
|
||||
}
|
||||
.petal-tag-picker {
|
||||
animation: petal-suggestion-in 160ms ease both;
|
||||
}
|
||||
.petal-row-action {
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease, background 160ms ease;
|
||||
}
|
||||
.group:hover .petal-row-action,
|
||||
.petal-row-action:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.petal-row-action:hover {
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
/* Clamp a search snippet to two lines. */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Touch & tablet polish --------------------------------------------------
|
||||
On coarse pointers (tablets, phones) there's no hover, so make tap targets
|
||||
comfortable and reveal affordances that would otherwise be hover-only. */
|
||||
@media (pointer: coarse) {
|
||||
.petal-tap {
|
||||
min-height: 44px;
|
||||
}
|
||||
.petal-tap-sm {
|
||||
min-height: 36px;
|
||||
}
|
||||
/* Row actions can't rely on hover — keep them visible and roomy. */
|
||||
.petal-row-action {
|
||||
opacity: 1;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
}
|
||||
/* Bigger, easier-to-hit suggestion/word/tag pills. */
|
||||
.petal-tag-chip {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Responsive sidebar (narrow screens) ------------------------------------
|
||||
Below the tablet breakpoint the sidebar becomes an overlay drawer toggled by a
|
||||
hamburger in the header, instead of a permanent column. A scrim sits behind it.
|
||||
On wide screens the toggle + scrim are hidden and the sidebar is in-flow. */
|
||||
.petal-sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
.petal-scrim {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.petal-sidebar-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
.petal-sidebar {
|
||||
position: fixed;
|
||||
top: 48px; /* below the 12-height header */
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
background: var(--color-bg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
/* On mobile the sidebar is hidden by default; the drawer-open class slides it
|
||||
in. (Distraction-free's petal-sidebar-hidden still wins to keep it closed.) */
|
||||
.petal-sidebar:not(.petal-drawer-open) {
|
||||
width: 0;
|
||||
transform: translateX(-24px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.petal-scrim.petal-scrim-show {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 48px 0 0 0;
|
||||
z-index: 20;
|
||||
background: rgba(61, 46, 57, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
/* Print / Save-as-PDF: strip every bit of app chrome and editing decoration so
|
||||
only the title and the writing itself reach the page. This is Petal's PDF
|
||||
path — it uses the browser's own fonts, so CJK renders correctly with no
|
||||
|
||||
Reference in New Issue
Block a user