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 { 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 (
|
||||
|
||||
@ -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">
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => handleTitleChange(e.target.value)}
|
||||
placeholder="无标题"
|
||||
className="w-full bg-transparent outline-none text-3xl font-bold"
|
||||
style={{ color: 'var(--text)', letterSpacing: '-0.02em' }}
|
||||
/>
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => handleTitleChange(e.target.value)}
|
||||
placeholder="无标题"
|
||||
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 */}
|
||||
|
||||
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 {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
})
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user