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 { Editor } from './components/editor/Editor'
import { useAppStore } from './stores/appStore'
import { seedIfEmpty } from './db'
import { seedIfEmpty, deduplicateDB } from './db'
export default function App() {
const { loadAll, theme } = useAppStore()
@ -12,7 +12,9 @@ export default function App() {
}, [theme])
useEffect(() => {
seedIfEmpty().then(() => loadAll())
seedIfEmpty()
.then(() => deduplicateDB())
.then(() => loadAll())
}, [])
return (

View File

@ -8,6 +8,7 @@ import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import { Table } from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import { WelcomeView } from './WelcomeView'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
import CharacterCount from '@tiptap/extension-character-count'
@ -19,7 +20,7 @@ import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
Highlighter, List, ListOrdered, Quote,
Heading1, Heading2, Heading3, Minus, CheckSquare, Table as TableIcon,
Type,
Type, Star,
} from 'lucide-react'
import { useAppStore } from '../../stores/appStore'
import { countWords } from '../../lib/utils'
@ -66,7 +67,7 @@ function SlashMenu({ items, selectedIndex, onSelect }: {
}
export function Editor() {
const { activeNoteId, notes, updateNote } = useAppStore()
const { activeNoteId, notes, updateNote, toggleStar } = useAppStore()
const activeNote = notes.find(n => n.id === activeNoteId)
const [title, setTitle] = useState(activeNote?.title ?? '')
@ -219,28 +220,35 @@ export function Editor() {
}, 500)
}
if (!activeNote) {
return (
<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>
)
if (!activeNote || activeNoteId === '__welcome__') {
return <WelcomeView />
}
return (
<div className="flex-1 flex flex-col min-w-0 h-full" style={{ background: 'var(--bg)' }}>
{/* Title */}
<div className="px-12 pt-10 pb-0 max-w-3xl mx-auto w-full">
<div className="flex items-start gap-3">
<input
value={title}
onChange={e => handleTitleChange(e.target.value)}
placeholder="无标题"
className="w-full bg-transparent outline-none text-3xl font-bold"
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>
{/* 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 {
Search, Plus, Star, FileText, Folder, FolderOpen,
ChevronRight, ChevronDown,
Trash2, Edit2, Moon, Sun, FolderPlus, Hash,
Trash2, Edit2, Moon, Sun, FolderPlus, Hash, BookOpen,
} from 'lucide-react'
import { useAppStore } from '../../stores/appStore'
import { formatDate } from '../../lib/utils'
import type { Folder as FolderType } from '../../db'
import type { Folder as FolderType, Note } from '../../db'
export function Sidebar() {
const {
notes, folders, activeNoteId, activeFolderId, searchQuery,
theme, createNote, createFolder, deleteNote, deleteFolder,
updateFolder, setActiveNote, setActiveFolder, setSearch,
toggleTheme, filteredNotes,
toggleTheme, toggleStar, filteredNotes,
} = useAppStore()
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())
@ -117,8 +117,9 @@ export function Sidebar() {
{/* Nav shortcuts */}
<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={<Star size={14} />} label="收藏" count={notes.filter(n => n.starred).length} active={activeFolderId === 'starred'} onClick={() => setActiveFolder('starred')} />
<NavItem icon={<BookOpen size={14} />} label="使用指南" active={activeNoteId === '__welcome__'} onClick={() => setActiveNote('__welcome__')} />
<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>
{/* Folders section */}
@ -190,38 +191,18 @@ export function Sidebar() {
</div>
)}
{displayed.map(note => (
<div
<NoteItem
key={note.id}
className="px-2 py-2 rounded-lg cursor-pointer"
style={{
background: activeNoteId === note.id ? 'var(--accent-subtle)' : 'transparent',
borderLeft: activeNoteId === note.id ? '2px solid var(--accent)' : '2px solid transparent',
}}
note={note}
active={activeNoteId === note.id}
onClick={() => setActiveNote(note.id)}
onDelete={() => deleteNote(note.id)}
onToggleStar={() => toggleStar(note.id)}
onContextMenu={(e) => {
e.preventDefault()
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>
@ -304,3 +285,73 @@ function CtxItem({ icon, label, danger, onClick }: { icon: React.ReactNode; labe
</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 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()
if (count > 0) return
@ -57,34 +65,6 @@ export async function seedIfEmpty() {
])
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(),
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) => ({
notes: [],
folders: [],
activeNoteId: null,
activeNoteId: '__welcome__' as string | null,
activeFolderId: 'all',
searchQuery: '',
theme: (localStorage.getItem('theme') as 'light' | 'dark') || 'light',
@ -43,8 +43,10 @@ export const useAppStore = create<AppState>((set, get) => ({
db.folders.orderBy('order').toArray(),
])
set({ notes, folders })
if (notes.length > 0 && !get().activeNoteId) {
set({ activeNoteId: notes[0].id })
// Keep welcome screen as default; only auto-select if already on a real note
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)
set(s => {
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 }
})
},