feat: add welcome view, fix duplicate seeding, add star/delete UI
- Replace editable intro note with a static read-only WelcomeView component - Add "使用指南" sidebar nav item always pointing to WelcomeView - Fix React StrictMode double-seed race condition via module-level Promise singleton - Add deduplicateDB() to clean up already-inserted duplicates on startup - Add star button in editor title bar and sidebar note hover row - Add visible delete button on note item hover in sidebar - Default activeNoteId to '__welcome__' sentinel; deleted notes return to WelcomeView Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
45871ac481
commit
bf2b16c78c
@ -2,7 +2,7 @@ import { useEffect } from 'react'
|
|||||||
import { Sidebar } from './components/sidebar/Sidebar'
|
import { Sidebar } from './components/sidebar/Sidebar'
|
||||||
import { Editor } from './components/editor/Editor'
|
import { Editor } from './components/editor/Editor'
|
||||||
import { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
import { seedIfEmpty } from './db'
|
import { seedIfEmpty, deduplicateDB } from './db'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadAll, theme } = useAppStore()
|
const { loadAll, theme } = useAppStore()
|
||||||
@ -12,7 +12,9 @@ export default function App() {
|
|||||||
}, [theme])
|
}, [theme])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
seedIfEmpty().then(() => loadAll())
|
seedIfEmpty()
|
||||||
|
.then(() => deduplicateDB())
|
||||||
|
.then(() => loadAll())
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import TaskList from '@tiptap/extension-task-list'
|
|||||||
import TaskItem from '@tiptap/extension-task-item'
|
import TaskItem from '@tiptap/extension-task-item'
|
||||||
import { Table } from '@tiptap/extension-table'
|
import { Table } from '@tiptap/extension-table'
|
||||||
import TableRow from '@tiptap/extension-table-row'
|
import TableRow from '@tiptap/extension-table-row'
|
||||||
|
import { WelcomeView } from './WelcomeView'
|
||||||
import TableCell from '@tiptap/extension-table-cell'
|
import TableCell from '@tiptap/extension-table-cell'
|
||||||
import TableHeader from '@tiptap/extension-table-header'
|
import TableHeader from '@tiptap/extension-table-header'
|
||||||
import CharacterCount from '@tiptap/extension-character-count'
|
import CharacterCount from '@tiptap/extension-character-count'
|
||||||
@ -19,7 +20,7 @@ import {
|
|||||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
|
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
|
||||||
Highlighter, List, ListOrdered, Quote,
|
Highlighter, List, ListOrdered, Quote,
|
||||||
Heading1, Heading2, Heading3, Minus, CheckSquare, Table as TableIcon,
|
Heading1, Heading2, Heading3, Minus, CheckSquare, Table as TableIcon,
|
||||||
Type,
|
Type, Star,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAppStore } from '../../stores/appStore'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
import { countWords } from '../../lib/utils'
|
import { countWords } from '../../lib/utils'
|
||||||
@ -66,7 +67,7 @@ function SlashMenu({ items, selectedIndex, onSelect }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Editor() {
|
export function Editor() {
|
||||||
const { activeNoteId, notes, updateNote } = useAppStore()
|
const { activeNoteId, notes, updateNote, toggleStar } = useAppStore()
|
||||||
const activeNote = notes.find(n => n.id === activeNoteId)
|
const activeNote = notes.find(n => n.id === activeNoteId)
|
||||||
|
|
||||||
const [title, setTitle] = useState(activeNote?.title ?? '')
|
const [title, setTitle] = useState(activeNote?.title ?? '')
|
||||||
@ -219,28 +220,35 @@ export function Editor() {
|
|||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeNote) {
|
if (!activeNote || activeNoteId === '__welcome__') {
|
||||||
return (
|
return <WelcomeView />
|
||||||
<div className="flex-1 flex items-center justify-center" style={{ color: 'var(--text-faint)' }}>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-5xl mb-4 opacity-20">✏️</div>
|
|
||||||
<p className="text-base">选择一篇笔记,或创建新笔记</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-w-0 h-full" style={{ background: 'var(--bg)' }}>
|
<div className="flex-1 flex flex-col min-w-0 h-full" style={{ background: 'var(--bg)' }}>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="px-12 pt-10 pb-0 max-w-3xl mx-auto w-full">
|
<div className="px-12 pt-10 pb-0 max-w-3xl mx-auto w-full">
|
||||||
<input
|
<div className="flex items-start gap-3">
|
||||||
value={title}
|
<input
|
||||||
onChange={e => handleTitleChange(e.target.value)}
|
value={title}
|
||||||
placeholder="无标题"
|
onChange={e => handleTitleChange(e.target.value)}
|
||||||
className="w-full bg-transparent outline-none text-3xl font-bold"
|
placeholder="无标题"
|
||||||
style={{ color: 'var(--text)', letterSpacing: '-0.02em' }}
|
className="flex-1 bg-transparent outline-none text-3xl font-bold min-w-0"
|
||||||
/>
|
style={{ color: 'var(--text)', letterSpacing: '-0.02em' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => activeNoteId && toggleStar(activeNoteId)}
|
||||||
|
title={activeNote.starred ? '取消收藏' : '收藏'}
|
||||||
|
className="toolbar-btn shrink-0 mt-2"
|
||||||
|
style={{ width: 28, height: 28 }}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={16}
|
||||||
|
fill={activeNote.starred ? '#f59e0b' : 'none'}
|
||||||
|
style={{ color: activeNote.starred ? '#f59e0b' : 'var(--text-faint)' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating bubble menu rendered via portal */}
|
{/* Floating bubble menu rendered via portal */}
|
||||||
|
|||||||
176
src/components/editor/WelcomeView.tsx
Normal file
176
src/components/editor/WelcomeView.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import {
|
||||||
|
FileText, Folder, Slash, MousePointer, Save,
|
||||||
|
Search, Moon, Plus, Hash,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAppStore } from '../../stores/appStore'
|
||||||
|
|
||||||
|
const FEATURES = [
|
||||||
|
{
|
||||||
|
icon: <FileText size={18} />,
|
||||||
|
title: '富文本编辑',
|
||||||
|
desc: '标题、列表、引用、代码块、表格、任务清单,应有尽有',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Slash size={18} />,
|
||||||
|
title: '斜杠命令',
|
||||||
|
desc: '输入 / 唤出命令菜单,用键盘快速插入任意内容块',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <MousePointer size={18} />,
|
||||||
|
title: '浮动工具栏',
|
||||||
|
desc: '选中文字后浮现格式工具栏,点击即可加粗、高亮、转标题',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Save size={18} />,
|
||||||
|
title: '自动保存',
|
||||||
|
desc: '停止输入 1 秒后自动写入本地 IndexedDB,无需手动保存',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Folder size={18} />,
|
||||||
|
title: '文件夹管理',
|
||||||
|
desc: '创建多级文件夹,右键可重命名或删除,拖拽随意整理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Search size={18} />,
|
||||||
|
title: '全文搜索',
|
||||||
|
desc: '侧边栏搜索框实时过滤笔记标题和标签',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Hash size={18} />,
|
||||||
|
title: '标签系统',
|
||||||
|
desc: '为笔记添加多个标签,跨文件夹快速定位相关内容',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Moon size={18} />,
|
||||||
|
title: '深色模式',
|
||||||
|
desc: '点击左上角图标一键切换亮色 / 暗色主题',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const SHORTCUTS = [
|
||||||
|
{ keys: ['⌘', 'B'], desc: '粗体' },
|
||||||
|
{ keys: ['⌘', 'I'], desc: '斜体' },
|
||||||
|
{ keys: ['⌘', 'U'], desc: '下划线' },
|
||||||
|
{ keys: ['⌘', 'Z'], desc: '撤销' },
|
||||||
|
{ keys: ['/'], desc: '命令菜单' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function WelcomeView() {
|
||||||
|
const { createNote } = useAppStore()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-y-auto h-full"
|
||||||
|
style={{ background: 'var(--bg)' }}
|
||||||
|
>
|
||||||
|
<div className="max-w-2xl mx-auto px-12 pt-16 pb-20">
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="mb-12">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-6 text-2xl"
|
||||||
|
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="text-4xl font-bold mb-3"
|
||||||
|
style={{ color: 'var(--text)', letterSpacing: '-0.03em', lineHeight: 1.15 }}
|
||||||
|
>
|
||||||
|
欢迎使用笔记
|
||||||
|
</h1>
|
||||||
|
<p className="text-base" style={{ color: 'var(--text-muted)', lineHeight: 1.7 }}>
|
||||||
|
一款基于 Vite + React + TipTap 构建的现代笔记应用。
|
||||||
|
<br />
|
||||||
|
点击左侧笔记开始阅读,或新建一篇属于你的笔记。
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => createNote(null)}
|
||||||
|
className="inline-flex items-center gap-2 mt-6 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
background: 'var(--accent)',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--accent-hover)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'var(--accent)')}
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
新建笔记
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', marginBottom: '2.5rem' }} />
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<h2
|
||||||
|
className="text-xs font-semibold uppercase tracking-widest mb-5"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
功能一览
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-12">
|
||||||
|
{FEATURES.map(f => (
|
||||||
|
<div
|
||||||
|
key={f.title}
|
||||||
|
className="rounded-xl p-4"
|
||||||
|
style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center justify-center w-8 h-8 rounded-lg mb-3"
|
||||||
|
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
{f.icon}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold mb-1" style={{ color: 'var(--text)' }}>{f.title}</div>
|
||||||
|
<div className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>{f.desc}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shortcuts */}
|
||||||
|
<h2
|
||||||
|
className="text-xs font-semibold uppercase tracking-widest mb-4"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
常用快捷键
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="rounded-xl overflow-hidden"
|
||||||
|
style={{ border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
{SHORTCUTS.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={s.desc}
|
||||||
|
className="flex items-center justify-between px-5 py-3"
|
||||||
|
style={{
|
||||||
|
borderTop: i > 0 ? '1px solid var(--border)' : 'none',
|
||||||
|
background: 'var(--bg-subtle)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-sm" style={{ color: 'var(--text-muted)' }}>{s.desc}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{s.keys.map(k => (
|
||||||
|
<kbd
|
||||||
|
key={k}
|
||||||
|
className="inline-flex items-center justify-center px-2 py-0.5 rounded text-xs font-mono"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
minWidth: 28,
|
||||||
|
boxShadow: '0 1px 0 var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{k}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -2,18 +2,18 @@ import { useState, useRef, useEffect } from 'react'
|
|||||||
import {
|
import {
|
||||||
Search, Plus, Star, FileText, Folder, FolderOpen,
|
Search, Plus, Star, FileText, Folder, FolderOpen,
|
||||||
ChevronRight, ChevronDown,
|
ChevronRight, ChevronDown,
|
||||||
Trash2, Edit2, Moon, Sun, FolderPlus, Hash,
|
Trash2, Edit2, Moon, Sun, FolderPlus, Hash, BookOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAppStore } from '../../stores/appStore'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
import { formatDate } from '../../lib/utils'
|
import { formatDate } from '../../lib/utils'
|
||||||
import type { Folder as FolderType } from '../../db'
|
import type { Folder as FolderType, Note } from '../../db'
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const {
|
const {
|
||||||
notes, folders, activeNoteId, activeFolderId, searchQuery,
|
notes, folders, activeNoteId, activeFolderId, searchQuery,
|
||||||
theme, createNote, createFolder, deleteNote, deleteFolder,
|
theme, createNote, createFolder, deleteNote, deleteFolder,
|
||||||
updateFolder, setActiveNote, setActiveFolder, setSearch,
|
updateFolder, setActiveNote, setActiveFolder, setSearch,
|
||||||
toggleTheme, filteredNotes,
|
toggleTheme, toggleStar, filteredNotes,
|
||||||
} = useAppStore()
|
} = useAppStore()
|
||||||
|
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())
|
||||||
@ -117,8 +117,9 @@ export function Sidebar() {
|
|||||||
|
|
||||||
{/* Nav shortcuts */}
|
{/* Nav shortcuts */}
|
||||||
<nav className="px-2 space-y-0.5">
|
<nav className="px-2 space-y-0.5">
|
||||||
<NavItem icon={<FileText size={14} />} label="所有笔记" count={notes.length} active={activeFolderId === 'all'} onClick={() => setActiveFolder('all')} />
|
<NavItem icon={<BookOpen size={14} />} label="使用指南" active={activeNoteId === '__welcome__'} onClick={() => setActiveNote('__welcome__')} />
|
||||||
<NavItem icon={<Star size={14} />} label="收藏" count={notes.filter(n => n.starred).length} active={activeFolderId === 'starred'} onClick={() => setActiveFolder('starred')} />
|
<NavItem icon={<FileText size={14} />} label="所有笔记" count={notes.length} active={activeFolderId === 'all' && activeNoteId !== '__welcome__'} onClick={() => { setActiveFolder('all'); if (activeNoteId === '__welcome__') setActiveNote(null) }} />
|
||||||
|
<NavItem icon={<Star size={14} />} label="收藏" count={notes.filter(n => n.starred).length} active={activeFolderId === 'starred'} onClick={() => { setActiveFolder('starred'); if (activeNoteId === '__welcome__') setActiveNote(null) }} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Folders section */}
|
{/* Folders section */}
|
||||||
@ -190,38 +191,18 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{displayed.map(note => (
|
{displayed.map(note => (
|
||||||
<div
|
<NoteItem
|
||||||
key={note.id}
|
key={note.id}
|
||||||
className="px-2 py-2 rounded-lg cursor-pointer"
|
note={note}
|
||||||
style={{
|
active={activeNoteId === note.id}
|
||||||
background: activeNoteId === note.id ? 'var(--accent-subtle)' : 'transparent',
|
|
||||||
borderLeft: activeNoteId === note.id ? '2px solid var(--accent)' : '2px solid transparent',
|
|
||||||
}}
|
|
||||||
onClick={() => setActiveNote(note.id)}
|
onClick={() => setActiveNote(note.id)}
|
||||||
|
onDelete={() => deleteNote(note.id)}
|
||||||
|
onToggleStar={() => toggleStar(note.id)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setContextMenu({ type: 'note', id: note.id, x: e.clientX, y: e.clientY })
|
setContextMenu({ type: 'note', id: note.id, x: e.clientX, y: e.clientY })
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<div className="flex items-start justify-between gap-1">
|
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
|
||||||
{note.starred && <Star size={11} style={{ color: '#f59e0b', flexShrink: 0 }} fill="currentColor" />}
|
|
||||||
<span className="text-sm font-medium truncate" style={{ color: activeNoteId === note.id ? 'var(--accent)' : 'var(--text)' }}>
|
|
||||||
{note.title || '无标题笔记'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs shrink-0 mt-0.5" style={{ color: 'var(--text-faint)' }}>{formatDate(note.updatedAt)}</span>
|
|
||||||
</div>
|
|
||||||
{note.tags.length > 0 && (
|
|
||||||
<div className="flex gap-1 mt-1 flex-wrap">
|
|
||||||
{note.tags.slice(0, 3).map(tag => (
|
|
||||||
<span key={tag} className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-muted)', color: 'var(--text-muted)' }}>
|
|
||||||
<Hash size={9} />{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -304,3 +285,73 @@ function CtxItem({ icon, label, danger, onClick }: { icon: React.ReactNode; labe
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NoteItem({ note, active, onClick, onDelete, onToggleStar, onContextMenu }: {
|
||||||
|
note: Note
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onToggleStar: () => void
|
||||||
|
onContextMenu: (e: React.MouseEvent) => void
|
||||||
|
}) {
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="px-2 py-2 rounded-lg cursor-pointer relative"
|
||||||
|
style={{
|
||||||
|
background: active ? 'var(--accent-subtle)' : hovered ? 'var(--bg-muted)' : 'transparent',
|
||||||
|
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||||
|
}}
|
||||||
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-1">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
|
{note.starred && <Star size={11} style={{ color: '#f59e0b', flexShrink: 0 }} fill="currentColor" />}
|
||||||
|
<span className="text-sm font-medium truncate" style={{ color: active ? 'var(--accent)' : 'var(--text)' }}>
|
||||||
|
{note.title || '无标题笔记'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hovered ? (
|
||||||
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
|
<button
|
||||||
|
className="toolbar-btn"
|
||||||
|
style={{ width: 20, height: 20, color: note.starred ? '#f59e0b' : 'var(--text-faint)' }}
|
||||||
|
title={note.starred ? '取消收藏' : '收藏'}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggleStar() }}
|
||||||
|
>
|
||||||
|
<Star size={12} fill={note.starred ? '#f59e0b' : 'none'} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="toolbar-btn"
|
||||||
|
style={{ width: 20, height: 20, color: '#ef4444', opacity: 0.8 }}
|
||||||
|
title="删除笔记"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete() }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.opacity = '0.8')}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs shrink-0 mt-0.5" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{formatDate(note.updatedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{note.tags.length > 0 && (
|
||||||
|
<div className="flex gap-1 mt-1 flex-wrap">
|
||||||
|
{note.tags.slice(0, 3).map(tag => (
|
||||||
|
<span key={tag} className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full"
|
||||||
|
style={{ background: 'var(--bg-muted)', color: 'var(--text-muted)' }}>
|
||||||
|
<Hash size={9} />{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -43,7 +43,15 @@ class NotesDB extends Dexie {
|
|||||||
|
|
||||||
export const db = new NotesDB()
|
export const db = new NotesDB()
|
||||||
|
|
||||||
export async function seedIfEmpty() {
|
// Module-level singleton prevents double-seeding under React StrictMode
|
||||||
|
let _seedPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
export function seedIfEmpty(): Promise<void> {
|
||||||
|
if (!_seedPromise) _seedPromise = _doSeed()
|
||||||
|
return _seedPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _doSeed() {
|
||||||
const count = await db.notes.count()
|
const count = await db.notes.count()
|
||||||
if (count > 0) return
|
if (count > 0) return
|
||||||
|
|
||||||
@ -57,34 +65,6 @@ export async function seedIfEmpty() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
await db.notes.bulkAdd([
|
await db.notes.bulkAdd([
|
||||||
{
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
title: '欢迎使用笔记应用',
|
|
||||||
content: JSON.stringify({
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: '欢迎使用 ✨' }] },
|
|
||||||
{ type: 'paragraph', content: [{ type: 'text', text: '这是一款基于 Vite + React + TipTap 构建的现代笔记应用。' }] },
|
|
||||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '功能特性' }] },
|
|
||||||
{ type: 'bulletList', content: [
|
|
||||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '支持富文本编辑(标题、列表、代码块、引用等)' }] }] },
|
|
||||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '输入 / 呼出斜杠命令菜单' }] }] },
|
|
||||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '选中文字查看浮动工具栏' }] }] },
|
|
||||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '自动保存到 IndexedDB' }] }] },
|
|
||||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '文件夹分类管理' }] }] },
|
|
||||||
]},
|
|
||||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '代码示例' }] },
|
|
||||||
{ type: 'codeBlock', attrs: { language: 'typescript' }, content: [{ type: 'text', text: 'const hello = "Hello, World!"\nconsole.log(hello)' }] },
|
|
||||||
{ type: 'blockquote', content: [{ type: 'paragraph', content: [{ type: 'text', text: '好记性不如烂笔头。开始记录你的想法吧!' }] }] },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
folderId: null,
|
|
||||||
tags: ['入门'],
|
|
||||||
starred: true,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
wordCount: 80,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: '项目计划',
|
title: '项目计划',
|
||||||
@ -127,3 +107,44 @@ export async function seedIfEmpty() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Removes duplicate folders and notes inserted by React StrictMode double-mount.
|
||||||
|
// Groups by (name + parentId) for folders, (title + createdAt bucket) for notes.
|
||||||
|
export async function deduplicateDB() {
|
||||||
|
// --- folders ---
|
||||||
|
const allFolders = await db.folders.toArray()
|
||||||
|
const folderSeen = new Map<string, string>() // key → keep id
|
||||||
|
const folderRemap = new Map<string, string>() // deleted id → kept id
|
||||||
|
|
||||||
|
for (const f of allFolders) {
|
||||||
|
const key = `${f.parentId ?? ''}::${f.name}`
|
||||||
|
if (!folderSeen.has(key)) {
|
||||||
|
folderSeen.set(key, f.id)
|
||||||
|
} else {
|
||||||
|
folderRemap.set(f.id, folderSeen.get(key)!)
|
||||||
|
await db.folders.delete(f.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-point notes that referenced a deleted folder
|
||||||
|
if (folderRemap.size > 0) {
|
||||||
|
const notesToFix = await db.notes.filter(n => n.folderId !== null && folderRemap.has(n.folderId!)).toArray()
|
||||||
|
for (const n of notesToFix) {
|
||||||
|
await db.notes.update(n.id, { folderId: folderRemap.get(n.folderId!)! })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- notes: deduplicate by title + createdAt within 2 s window ---
|
||||||
|
const allNotes = await db.notes.toArray()
|
||||||
|
const noteSeen = new Map<string, number>() // key → kept createdAt
|
||||||
|
|
||||||
|
for (const n of allNotes) {
|
||||||
|
const bucket = Math.floor(n.createdAt / 2000)
|
||||||
|
const key = `${n.title}::${bucket}`
|
||||||
|
if (!noteSeen.has(key)) {
|
||||||
|
noteSeen.set(key, n.createdAt)
|
||||||
|
} else {
|
||||||
|
await db.notes.delete(n.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -32,7 +32,7 @@ interface AppState {
|
|||||||
export const useAppStore = create<AppState>((set, get) => ({
|
export const useAppStore = create<AppState>((set, get) => ({
|
||||||
notes: [],
|
notes: [],
|
||||||
folders: [],
|
folders: [],
|
||||||
activeNoteId: null,
|
activeNoteId: '__welcome__' as string | null,
|
||||||
activeFolderId: 'all',
|
activeFolderId: 'all',
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
theme: (localStorage.getItem('theme') as 'light' | 'dark') || 'light',
|
theme: (localStorage.getItem('theme') as 'light' | 'dark') || 'light',
|
||||||
@ -43,8 +43,10 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
db.folders.orderBy('order').toArray(),
|
db.folders.orderBy('order').toArray(),
|
||||||
])
|
])
|
||||||
set({ notes, folders })
|
set({ notes, folders })
|
||||||
if (notes.length > 0 && !get().activeNoteId) {
|
// Keep welcome screen as default; only auto-select if already on a real note
|
||||||
set({ activeNoteId: notes[0].id })
|
const cur = get().activeNoteId
|
||||||
|
if (cur && cur !== '__welcome__' && !notes.find(n => n.id === cur)) {
|
||||||
|
set({ activeNoteId: '__welcome__' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -81,7 +83,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
await db.notes.delete(id)
|
await db.notes.delete(id)
|
||||||
set(s => {
|
set(s => {
|
||||||
const notes = s.notes.filter(n => n.id !== id)
|
const notes = s.notes.filter(n => n.id !== id)
|
||||||
const activeNoteId = s.activeNoteId === id ? (notes[0]?.id ?? null) : s.activeNoteId
|
const activeNoteId = s.activeNoteId === id ? '__welcome__' : s.activeNoteId
|
||||||
return { notes, activeNoteId }
|
return { notes, activeNoteId }
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user