Reuse the companion's prose.ts rules engine as the single source of
deterministic detection instead of duplicating it. Applyable rules now
also emit exact-span fixes (original -> replacement) that surface as
suggestion cards; awareness-only rules (run-ons, splices, ...) stay
companion bubbles. The companion hides fix-bearing hints so a span is
never both a bubble and a card.
Spans are widened to a distinctive phrase ("a old" -> "an old",
"She have" -> "She has") so they re-anchor by string in the editor; a
lone lowercase "i" stays awareness-only since a single char can't anchor.
Backend: detection lives client-side, so the new persist-only
POST /docs/{id}/mechanics endpoint receives findings and stores them as
the 'mechanics' family with their exact offsets. It honours
actioned-suppression, leaves the LLM families untouched, and a checkpoint
no longer wipes it. fetchPending dedupes spans with mechanics winning any
collision against an LLM card (its span is exact). Migration 0008 adds the
'mechanics' suggestion type.
Client renders the mechanics fixes immediately (no LLM wait) and the cards
use a calm sage "Tidy-up" accent.
Verified end-to-end in a real browser on millenia: detect -> persist ->
render -> accept applies the fix.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
559 lines
20 KiB
TypeScript
559 lines
20 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
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'
|
|
import { ExportMenu } from './components/Export/ExportMenu'
|
|
import { HistoryPanel } from './components/History/HistoryPanel'
|
|
import { GardenPanel } from './components/Garden/GardenPanel'
|
|
import { StatusBar } from './components/StatusBar/StatusBar'
|
|
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
|
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
|
import { useVersionWatch } from './hooks/useVersionWatch'
|
|
import { PetalFall } from './effects/PetalFall'
|
|
import { useNightMode } from './hooks/useNightMode'
|
|
import { playSuggestionSound } from './audio/sounds'
|
|
|
|
export default function App() {
|
|
const updateAvailable = useVersionWatch()
|
|
// Late-night calm mode: dark theme + falling stars instead of petals. The hook
|
|
// toggles the `petal-night` class on <html>; we pass the flag to the ambient
|
|
// layer so the petals become stars.
|
|
const night = useNightMode()
|
|
const [docs, setDocs] = useState<DocSummary[]>([])
|
|
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
|
const [title, setTitle] = useState('')
|
|
const [wordCount, setWordCount] = useState(0)
|
|
// The current document's target tone (steers checkpoint advice) and its live
|
|
// plain text (drives the expanded writing-stats panel in the StatusBar).
|
|
const [tone, setTone] = useState('general')
|
|
const [docText, setDocText] = useState('')
|
|
const [ready, setReady] = useState(false)
|
|
// 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)
|
|
const [acceptTick, setAcceptTick] = useState(0)
|
|
// History drawer visibility, and an epoch bumped on restore to force the
|
|
// editor to remount with the restored content (its initialContent is read
|
|
// only on mount).
|
|
const [historyOpen, setHistoryOpen] = useState(false)
|
|
const [gardenOpen, setGardenOpen] = useState(false)
|
|
const [editorEpoch, setEditorEpoch] = useState(0)
|
|
|
|
// Live mirrors of the current doc's editable fields so the "discard blank
|
|
// drafts on navigation" logic can read the latest values without rebuilding
|
|
// callbacks on every keystroke.
|
|
const currentDocRef = useRef(currentDoc)
|
|
const titleRef = useRef(title)
|
|
const wordCountRef = useRef(wordCount)
|
|
currentDocRef.current = currentDoc
|
|
titleRef.current = title
|
|
wordCountRef.current = wordCount
|
|
|
|
// A throwaway blank draft: no words and still the default/empty title. We
|
|
// delete these on navigation rather than leave orphan "Untitled" docs behind.
|
|
const isBlankDraft = useCallback(() => {
|
|
const t = titleRef.current.trim()
|
|
return wordCountRef.current === 0 && (t === '' || t === 'Untitled')
|
|
}, [])
|
|
|
|
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
|
|
const {
|
|
suggestions,
|
|
checking,
|
|
voicing,
|
|
collocating,
|
|
llmDown,
|
|
schedule: scheduleCheckpoint,
|
|
runVoice,
|
|
runCollocation,
|
|
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()
|
|
await saveNow() // flush any pending edits to the doc we're leaving
|
|
const doc = await api.getDoc(id)
|
|
setCurrentDoc(doc)
|
|
setTitle(doc.title)
|
|
setWordCount(doc.word_count)
|
|
setTone(doc.tone || 'general')
|
|
setDocText(doc.content_text)
|
|
// Leaving an untouched blank draft for a different doc? Drop it silently
|
|
// (best-effort cleanup — a 404 just means it's already gone).
|
|
if (leaving && leaving.id !== id && leavingBlank) {
|
|
api.deleteDoc(leaving.id).catch(() => {})
|
|
setDocs((prev) => prev.filter((d) => d.id !== leaving.id))
|
|
}
|
|
},
|
|
[saveNow, isBlankDraft],
|
|
)
|
|
|
|
// Initial load: fetch the list, opening the first doc (or creating one).
|
|
const bootedRef = useRef(false)
|
|
useEffect(() => {
|
|
if (bootedRef.current) return
|
|
bootedRef.current = true
|
|
;(async () => {
|
|
try {
|
|
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, tags: [] }]
|
|
setDocs(list)
|
|
setCurrentDoc(fresh)
|
|
setTitle(fresh.title)
|
|
setWordCount(0)
|
|
setTone(fresh.tone || 'general')
|
|
setDocText('')
|
|
} else {
|
|
setDocs(list)
|
|
await openDoc(list[0].id)
|
|
}
|
|
} catch (err) {
|
|
console.error('failed to load documents', err)
|
|
} finally {
|
|
setReady(true)
|
|
}
|
|
})()
|
|
}, [openDoc])
|
|
|
|
const handleCreate = useCallback(async () => {
|
|
// Already sitting on a fresh blank draft? Reuse it instead of stacking
|
|
// another empty "Untitled" on top.
|
|
if (currentDocRef.current && isBlankDraft()) return
|
|
await saveNow()
|
|
const fresh = await api.createDoc()
|
|
setDocs((prev) => [
|
|
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] },
|
|
...prev,
|
|
])
|
|
setCurrentDoc(fresh)
|
|
setTitle(fresh.title)
|
|
setWordCount(0)
|
|
setTone(fresh.tone || 'general')
|
|
setDocText('')
|
|
}, [saveNow, isBlankDraft])
|
|
|
|
// Duplicate a document: copy its body/tone into a fresh doc titled "… (副本)",
|
|
// then open the copy. Tags aren't carried over (a fresh start for the copy).
|
|
const handleDuplicate = useCallback(
|
|
async (id: string) => {
|
|
try {
|
|
await saveNow() // flush in case we're duplicating the open doc
|
|
const src = await api.getDoc(id)
|
|
const fresh = await api.createDoc()
|
|
const dupTitle = `${src.title?.trim() || 'Untitled'} (副本)`
|
|
const updated = await api.updateDoc(fresh.id, {
|
|
title: dupTitle,
|
|
content: src.content,
|
|
content_text: src.content_text,
|
|
tone: src.tone,
|
|
word_count: src.word_count,
|
|
})
|
|
setDocs((prev) => [
|
|
{ id: updated.id, title: updated.title, word_count: updated.word_count, updated_at: updated.updated_at, tags: [] },
|
|
...prev,
|
|
])
|
|
setCurrentDoc(updated)
|
|
setTitle(updated.title)
|
|
setWordCount(updated.word_count)
|
|
setTone(updated.tone || 'general')
|
|
setDocText(updated.content_text)
|
|
} catch (err) {
|
|
console.error('duplicate failed', err)
|
|
}
|
|
},
|
|
[saveNow],
|
|
)
|
|
|
|
const handleDelete = useCallback(
|
|
async (id: string) => {
|
|
await api.deleteDoc(id)
|
|
setDocs((prev) => {
|
|
const remaining = prev.filter((d) => d.id !== id)
|
|
if (currentDoc?.id === id) {
|
|
if (remaining.length > 0) {
|
|
void openDoc(remaining[0].id)
|
|
} else {
|
|
setCurrentDoc(null)
|
|
setTitle('')
|
|
setWordCount(0)
|
|
setTone('general')
|
|
setDocText('')
|
|
}
|
|
}
|
|
return remaining
|
|
})
|
|
},
|
|
[currentDoc, openDoc],
|
|
)
|
|
|
|
const handleTitleChange = useCallback(
|
|
(value: string) => {
|
|
setTitle(value)
|
|
if (currentDoc) {
|
|
patchSummary(currentDoc.id, { title: value })
|
|
schedule({ title: value })
|
|
}
|
|
},
|
|
[currentDoc, patchSummary, schedule],
|
|
)
|
|
|
|
const handleEditorChange = useCallback(
|
|
(change: EditorChange) => {
|
|
setWordCount(change.word_count)
|
|
setDocText(change.content_text)
|
|
setEditTick((n) => n + 1)
|
|
if (currentDoc) {
|
|
patchSummary(currentDoc.id, { word_count: change.word_count })
|
|
schedule(change)
|
|
scheduleCheckpoint(change.content_text)
|
|
}
|
|
},
|
|
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
|
|
)
|
|
|
|
// Changing the tone persists it and re-runs the checkpoint so Petal's advice
|
|
// re-tunes to the new register. The auto-save (1.5s) lands before the
|
|
// checkpoint debounce (4s), so the server reads the updated tone.
|
|
const handleToneChange = useCallback(
|
|
(value: string) => {
|
|
setTone(value)
|
|
if (currentDoc) {
|
|
schedule({ tone: value })
|
|
scheduleCheckpoint()
|
|
}
|
|
},
|
|
[currentDoc, schedule, scheduleCheckpoint],
|
|
)
|
|
|
|
// Accept applies the replacement in the editor (handled in EditorCore) and
|
|
// marks the suggestion accepted; dismiss just rejects it. Both drop it locally.
|
|
const handleAccept = useCallback(
|
|
async (s: Suggestion) => {
|
|
removeSuggestion(s.id)
|
|
setAcceptTick((n) => n + 1)
|
|
try {
|
|
await api.acceptSuggestion(s.id)
|
|
} catch (err) {
|
|
console.error('accept failed', err)
|
|
}
|
|
},
|
|
[removeSuggestion],
|
|
)
|
|
|
|
// After restoring a version, swap the restored doc into the editor. Bumping
|
|
// editorEpoch remounts EditorCore so it picks up the restored content.
|
|
const handleRestored = useCallback(
|
|
(doc: Document) => {
|
|
setCurrentDoc(doc)
|
|
setTitle(doc.title)
|
|
setWordCount(doc.word_count)
|
|
setTone(doc.tone || 'general')
|
|
setDocText(doc.content_text)
|
|
patchSummary(doc.id, { title: doc.title, word_count: doc.word_count })
|
|
setEditorEpoch((n) => n + 1)
|
|
},
|
|
[patchSummary],
|
|
)
|
|
|
|
// Escape always restores the sidebar while in distraction-free mode.
|
|
useEffect(() => {
|
|
if (!focusMode) return
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setFocusMode(false)
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
}, [focusMode])
|
|
|
|
// A pointer-down outside the centered editor canvas (the cream gutters, the
|
|
// header, the status bar) also restores the sidebar.
|
|
const handleChromeDown = useCallback((e: React.MouseEvent) => {
|
|
if (!canvasRef.current?.contains(e.target as Node)) setFocusMode(false)
|
|
}, [])
|
|
|
|
const handleDismiss = useCallback(
|
|
async (s: Suggestion) => {
|
|
removeSuggestion(s.id)
|
|
try {
|
|
await api.dismissSuggestion(s.id)
|
|
} catch (err) {
|
|
console.error('dismiss failed', err)
|
|
}
|
|
},
|
|
[removeSuggestion],
|
|
)
|
|
|
|
// Play a soft sound when freshly-checked suggestions arrive — one per distinct
|
|
// new type, lightly staggered so a batch reads as a little melody rather than
|
|
// a pile-up. We track which ids we've already chimed for, and only chime for
|
|
// recently-created suggestions so opening a doc with old pending advice stays
|
|
// silent (the existing set was created in a past session).
|
|
const chimedRef = useRef<Set<string>>(new Set())
|
|
useEffect(() => {
|
|
const fresh = suggestions.filter((s) => !chimedRef.current.has(s.id))
|
|
fresh.forEach((s) => chimedRef.current.add(s.id))
|
|
const justMade = fresh.filter(
|
|
(s) => Date.now() - new Date(s.created_at).getTime() < 12_000,
|
|
)
|
|
if (justMade.length === 0) return
|
|
const types = [...new Set(justMade.map((s) => s.type))].slice(0, 3)
|
|
const timers = types.map((t, i) =>
|
|
window.setTimeout(() => playSuggestionSound(t), i * 150),
|
|
)
|
|
return () => timers.forEach((id) => window.clearTimeout(id))
|
|
}, [suggestions])
|
|
|
|
// Forget chimed ids when switching documents so the set can't grow unbounded.
|
|
useEffect(() => {
|
|
chimedRef.current = new Set()
|
|
}, [currentDoc?.id])
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<PetalFall night={night} />
|
|
<header
|
|
onMouseDown={handleChromeDown}
|
|
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>
|
|
<button
|
|
type="button"
|
|
onClick={() => setGardenOpen(true)}
|
|
aria-label="Vocabulary garden"
|
|
title="Words you've looked up, blooming for review"
|
|
className="petal-tap-sm ml-auto inline-flex h-9 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
|
style={{
|
|
borderRadius: 'var(--radius-pill)',
|
|
background: 'var(--color-surface)',
|
|
color: 'var(--color-plum)',
|
|
boxShadow: 'var(--shadow-soft)',
|
|
}}
|
|
>
|
|
<span aria-hidden>🌷</span>
|
|
<span>词汇花园</span>
|
|
<span style={{ color: 'var(--color-muted)' }}>· Garden</span>
|
|
</button>
|
|
</header>
|
|
|
|
<div className="relative flex min-h-0 flex-1">
|
|
<div
|
|
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}
|
|
onDuplicate={handleDuplicate}
|
|
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 ? (
|
|
<>
|
|
<div
|
|
onMouseDown={handleChromeDown}
|
|
className="flex flex-1 flex-col overflow-y-auto px-6 py-8"
|
|
>
|
|
<div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
|
|
<div className="mb-5 flex items-center gap-3">
|
|
<input
|
|
value={title}
|
|
onChange={(e) => handleTitleChange(e.target.value)}
|
|
placeholder="Untitled"
|
|
aria-label="Document title"
|
|
className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
|
|
style={{ fontFamily: 'var(--font-ui)' }}
|
|
/>
|
|
<div className="petal-no-print shrink-0">
|
|
<ToneSelect value={tone} onChange={handleToneChange} />
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setHistoryOpen(true)}
|
|
aria-label="Version history"
|
|
title="Browse and restore earlier versions"
|
|
className="petal-no-print inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
|
style={{
|
|
borderRadius: 'var(--radius-pill)',
|
|
background: 'var(--color-surface)',
|
|
color: 'var(--color-plum)',
|
|
boxShadow: 'var(--shadow-soft)',
|
|
}}
|
|
>
|
|
<span aria-hidden>🕘</span>
|
|
<span>历史</span>
|
|
<span style={{ color: 'var(--color-muted)' }}>· History</span>
|
|
</button>
|
|
<div className="petal-no-print">
|
|
<ExportMenu docId={currentDoc.id} />
|
|
</div>
|
|
</div>
|
|
<EditorCore
|
|
key={`${currentDoc.id}:${editorEpoch}`}
|
|
docId={currentDoc.id}
|
|
initialContent={currentDoc.content}
|
|
onChange={handleEditorChange}
|
|
suggestions={suggestions}
|
|
onAccept={handleAccept}
|
|
onDismiss={handleDismiss}
|
|
onVoiceCheck={runVoice}
|
|
voicing={voicing}
|
|
onCollocationCheck={runCollocation}
|
|
collocating={collocating}
|
|
onFocusMode={() => setFocusMode(true)}
|
|
spellChecker={spellChecker}
|
|
onAddWord={addWord}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="petal-no-print" onMouseDown={handleChromeDown}>
|
|
<StatusBar
|
|
wordCount={wordCount}
|
|
text={docText}
|
|
saveStatus={status}
|
|
checking={checking}
|
|
voicing={voicing}
|
|
collocating={collocating}
|
|
llmDown={llmDown}
|
|
/>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div
|
|
className="flex flex-1 items-center justify-center text-sm"
|
|
style={{ color: 'var(--color-muted)' }}
|
|
>
|
|
{ready ? 'Create a document to start writing.' : 'Loading…'}
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
|
|
{historyOpen && currentDoc && (
|
|
<HistoryPanel
|
|
docId={currentDoc.id}
|
|
onClose={() => setHistoryOpen(false)}
|
|
onRestored={handleRestored}
|
|
/>
|
|
)}
|
|
|
|
{gardenOpen && (
|
|
<GardenPanel
|
|
onClose={() => setGardenOpen(false)}
|
|
onOpenDoc={(id) => {
|
|
setGardenOpen(false)
|
|
void openDoc(id)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{updateAvailable && <UpdateBanner />}
|
|
|
|
<div className="petal-no-print">
|
|
<PetalCompanion
|
|
wordCount={wordCount}
|
|
saveStatus={status}
|
|
llmDown={llmDown}
|
|
editTick={editTick}
|
|
acceptTick={acceptTick}
|
|
text={docText}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|