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:
MikiVL 2026-05-02 19:16:50 +08:00
parent 45871ac481
commit bf2b16c78c
6 changed files with 344 additions and 84 deletions

View File

@ -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 (

View File

@ -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 */}

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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)
}
}
}

View File

@ -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 }
}) })
}, },