Includes all Phase 1 features: - Search-first navigation with auto-complete - Responsive reading interface (desktop/tablet/mobile) - 4 customization presets + full fine-tuning controls - Layered details panel with notes, bookmarks, highlights - Smart offline caching with IndexedDB and auto-sync - Full accessibility (WCAG 2.1 AA) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
867 lines
23 KiB
Markdown
867 lines
23 KiB
Markdown
# Rich Text Study Notes - Implementation Plan
|
|
|
|
## 📋 Overview
|
|
|
|
Implement a comprehensive rich text note-taking system allowing users to create detailed, formatted study notes with images, links, and advanced organization features for deep Bible study.
|
|
|
|
**Status:** Planning Phase
|
|
**Priority:** 🟡 Medium
|
|
**Estimated Time:** 2 weeks (80 hours)
|
|
**Target Completion:** TBD
|
|
|
|
---
|
|
|
|
## 🎯 Goals & Objectives
|
|
|
|
### Primary Goals
|
|
1. Provide rich text editing capabilities for study notes
|
|
2. Enable advanced formatting (bold, italic, lists, headers)
|
|
3. Support multimedia content (images, links, videos)
|
|
4. Organize notes with folders and tags
|
|
5. Enable search and filtering across all notes
|
|
|
|
### User Value Proposition
|
|
- **For students**: Comprehensive study journal
|
|
- **For scholars**: Research documentation
|
|
- **For teachers**: Lesson planning and preparation
|
|
- **For small groups**: Collaborative study materials
|
|
- **For personal growth**: Spiritual reflection journal
|
|
|
|
---
|
|
|
|
## ✨ Feature Specifications
|
|
|
|
### 1. Note Data Model
|
|
|
|
```typescript
|
|
interface StudyNote {
|
|
id: string
|
|
userId: string
|
|
|
|
// Content
|
|
title: string
|
|
content: string // Rich text (HTML or JSON)
|
|
contentType: 'html' | 'json' | 'markdown'
|
|
plainText: string // For search indexing
|
|
|
|
// References
|
|
verseReferences: VerseReference[]
|
|
relatedNotes: string[] // Note IDs
|
|
|
|
// Organization
|
|
folderId: string | null
|
|
tags: string[]
|
|
color: string // For visual organization
|
|
isPinned: boolean
|
|
isFavorite: boolean
|
|
|
|
// Collaboration
|
|
visibility: 'private' | 'shared' | 'public'
|
|
sharedWith: string[] // User IDs
|
|
|
|
// Metadata
|
|
createdAt: Date
|
|
updatedAt: Date
|
|
lastViewedAt: Date
|
|
version: number // For version history
|
|
wordCount: number
|
|
readingTime: number // minutes
|
|
}
|
|
|
|
interface NoteFolder {
|
|
id: string
|
|
userId: string
|
|
name: string
|
|
description?: string
|
|
parentId: string | null // For nested folders
|
|
color: string
|
|
icon: string
|
|
order: number
|
|
createdAt: Date
|
|
updatedAt: Date
|
|
}
|
|
|
|
interface VerseReference {
|
|
book: string
|
|
chapter: number
|
|
verse: number
|
|
endVerse?: number
|
|
context?: string // Surrounding text snippet
|
|
}
|
|
```
|
|
|
|
### 2. Rich Text Editor (TipTap)
|
|
|
|
```typescript
|
|
import { useEditor, EditorContent } from '@tiptap/react'
|
|
import StarterKit from '@tiptap/starter-kit'
|
|
import Highlight from '@tiptap/extension-highlight'
|
|
import Typography from '@tiptap/extension-typography'
|
|
import Link from '@tiptap/extension-link'
|
|
import Image from '@tiptap/extension-image'
|
|
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 TableCell from '@tiptap/extension-table-cell'
|
|
import TableHeader from '@tiptap/extension-table-header'
|
|
import Placeholder from '@tiptap/extension-placeholder'
|
|
|
|
// Custom verse reference extension
|
|
const VerseReference = Node.create({
|
|
name: 'verseReference',
|
|
group: 'inline',
|
|
inline: true,
|
|
atom: true,
|
|
|
|
addAttributes() {
|
|
return {
|
|
book: { default: null },
|
|
chapter: { default: null },
|
|
verse: { default: null },
|
|
text: { default: null }
|
|
}
|
|
},
|
|
|
|
parseHTML() {
|
|
return [{ tag: 'span[data-verse-ref]' }]
|
|
},
|
|
|
|
renderHTML({ node, HTMLAttributes }) {
|
|
return [
|
|
'span',
|
|
{
|
|
...HTMLAttributes,
|
|
'data-verse-ref': true,
|
|
class: 'verse-reference-chip',
|
|
contenteditable: 'false'
|
|
},
|
|
node.attrs.text || `${node.attrs.book} ${node.attrs.chapter}:${node.attrs.verse}`
|
|
]
|
|
}
|
|
})
|
|
|
|
interface NoteEditorProps {
|
|
note: StudyNote
|
|
onSave: (content: string) => void
|
|
autoSave?: boolean
|
|
readOnly?: boolean
|
|
}
|
|
|
|
export const NoteEditor: React.FC<NoteEditorProps> = ({
|
|
note,
|
|
onSave,
|
|
autoSave = true,
|
|
readOnly = false
|
|
}) => {
|
|
const editor = useEditor({
|
|
extensions: [
|
|
StarterKit.configure({
|
|
heading: { levels: [1, 2, 3, 4] },
|
|
code: { HTMLAttributes: { class: 'code-block' } }
|
|
}),
|
|
Highlight.configure({ multicolor: true }),
|
|
Typography,
|
|
Link.configure({
|
|
openOnClick: false,
|
|
HTMLAttributes: { class: 'prose-link' }
|
|
}),
|
|
Image.configure({
|
|
inline: true,
|
|
HTMLAttributes: { class: 'note-image' }
|
|
}),
|
|
TaskList,
|
|
TaskItem.configure({
|
|
nested: true
|
|
}),
|
|
Table.configure({ resizable: true }),
|
|
TableRow,
|
|
TableCell,
|
|
TableHeader,
|
|
Placeholder.configure({
|
|
placeholder: 'Start writing your study notes...',
|
|
showOnlyWhenEditable: true
|
|
}),
|
|
VerseReference
|
|
],
|
|
content: note.content,
|
|
editable: !readOnly,
|
|
autofocus: !readOnly,
|
|
onUpdate: ({ editor }) => {
|
|
if (autoSave) {
|
|
debouncedSave(editor.getHTML())
|
|
}
|
|
}
|
|
})
|
|
|
|
const debouncedSave = useDebounce((content: string) => {
|
|
onSave(content)
|
|
}, 1000)
|
|
|
|
if (!editor) return null
|
|
|
|
return (
|
|
<Box className="note-editor">
|
|
{!readOnly && <EditorToolbar editor={editor} />}
|
|
<EditorContent editor={editor} />
|
|
<EditorFooter editor={editor} />
|
|
</Box>
|
|
)
|
|
}
|
|
```
|
|
|
|
### 3. Editor Toolbar
|
|
|
|
```typescript
|
|
const EditorToolbar: React.FC<{ editor: Editor }> = ({ editor }) => {
|
|
const [linkDialogOpen, setLinkDialogOpen] = useState(false)
|
|
const [imageDialogOpen, setImageDialogOpen] = useState(false)
|
|
const [verseRefDialogOpen, setVerseRefDialogOpen] = useState(false)
|
|
|
|
return (
|
|
<Box className="editor-toolbar" sx={{
|
|
p: 1,
|
|
borderBottom: 1,
|
|
borderColor: 'divider',
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: 0.5
|
|
}}>
|
|
{/* Text Formatting */}
|
|
<ButtonGroup size="small">
|
|
<IconButton
|
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
color={editor.isActive('bold') ? 'primary' : 'default'}
|
|
title="Bold (Ctrl+B)"
|
|
>
|
|
<FormatBoldIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
color={editor.isActive('italic') ? 'primary' : 'default'}
|
|
title="Italic (Ctrl+I)"
|
|
>
|
|
<FormatItalicIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
color={editor.isActive('underline') ? 'primary' : 'default'}
|
|
title="Underline (Ctrl+U)"
|
|
>
|
|
<FormatUnderlinedIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
color={editor.isActive('strike') ? 'primary' : 'default'}
|
|
title="Strikethrough"
|
|
>
|
|
<FormatStrikethroughIcon />
|
|
</IconButton>
|
|
</ButtonGroup>
|
|
|
|
<Divider orientation="vertical" flexItem />
|
|
|
|
{/* Headings */}
|
|
<FormControl size="small" sx={{ minWidth: 120 }}>
|
|
<Select
|
|
value={
|
|
editor.isActive('heading', { level: 1 }) ? 'h1' :
|
|
editor.isActive('heading', { level: 2 }) ? 'h2' :
|
|
editor.isActive('heading', { level: 3 }) ? 'h3' :
|
|
editor.isActive('paragraph') ? 'p' : 'p'
|
|
}
|
|
onChange={(e) => {
|
|
const level = e.target.value
|
|
if (level === 'p') {
|
|
editor.chain().focus().setParagraph().run()
|
|
} else {
|
|
const headingLevel = parseInt(level.substring(1)) as 1 | 2 | 3
|
|
editor.chain().focus().setHeading({ level: headingLevel }).run()
|
|
}
|
|
}}
|
|
>
|
|
<MenuItem value="p">Paragraph</MenuItem>
|
|
<MenuItem value="h1">Heading 1</MenuItem>
|
|
<MenuItem value="h2">Heading 2</MenuItem>
|
|
<MenuItem value="h3">Heading 3</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<Divider orientation="vertical" flexItem />
|
|
|
|
{/* Lists */}
|
|
<ButtonGroup size="small">
|
|
<IconButton
|
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
color={editor.isActive('bulletList') ? 'primary' : 'default'}
|
|
title="Bullet List"
|
|
>
|
|
<FormatListBulletedIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
color={editor.isActive('orderedList') ? 'primary' : 'default'}
|
|
title="Numbered List"
|
|
>
|
|
<FormatListNumberedIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
|
color={editor.isActive('taskList') ? 'primary' : 'default'}
|
|
title="Task List"
|
|
>
|
|
<CheckBoxIcon />
|
|
</IconButton>
|
|
</ButtonGroup>
|
|
|
|
<Divider orientation="vertical" flexItem />
|
|
|
|
{/* Alignment */}
|
|
<ButtonGroup size="small">
|
|
<IconButton
|
|
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
|
color={editor.isActive({ textAlign: 'left' }) ? 'primary' : 'default'}
|
|
title="Align Left"
|
|
>
|
|
<FormatAlignLeftIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
|
color={editor.isActive({ textAlign: 'center' }) ? 'primary' : 'default'}
|
|
title="Align Center"
|
|
>
|
|
<FormatAlignCenterIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
|
color={editor.isActive({ textAlign: 'right' }) ? 'primary' : 'default'}
|
|
title="Align Right"
|
|
>
|
|
<FormatAlignRightIcon />
|
|
</IconButton>
|
|
</ButtonGroup>
|
|
|
|
<Divider orientation="vertical" flexItem />
|
|
|
|
{/* Highlights */}
|
|
<HighlightColorPicker
|
|
editor={editor}
|
|
onSelect={(color) => {
|
|
editor.chain().focus().toggleHighlight({ color }).run()
|
|
}}
|
|
/>
|
|
|
|
<Divider orientation="vertical" flexItem />
|
|
|
|
{/* Media & References */}
|
|
<ButtonGroup size="small">
|
|
<IconButton
|
|
onClick={() => setLinkDialogOpen(true)}
|
|
color={editor.isActive('link') ? 'primary' : 'default'}
|
|
title="Insert Link"
|
|
>
|
|
<LinkIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={() => setImageDialogOpen(true)}
|
|
title="Insert Image"
|
|
>
|
|
<ImageIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={() => setVerseRefDialogOpen(true)}
|
|
title="Insert Verse Reference"
|
|
>
|
|
<MenuBookIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
color={editor.isActive('codeBlock') ? 'primary' : 'default'}
|
|
title="Code Block"
|
|
>
|
|
<CodeIcon />
|
|
</IconButton>
|
|
</ButtonGroup>
|
|
|
|
<Divider orientation="vertical" flexItem />
|
|
|
|
{/* Undo/Redo */}
|
|
<ButtonGroup size="small">
|
|
<IconButton
|
|
onClick={() => editor.chain().focus().undo().run()}
|
|
disabled={!editor.can().undo()}
|
|
title="Undo (Ctrl+Z)"
|
|
>
|
|
<UndoIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={() => editor.chain().focus().redo().run()}
|
|
disabled={!editor.can().redo()}
|
|
title="Redo (Ctrl+Y)"
|
|
>
|
|
<RedoIcon />
|
|
</IconButton>
|
|
</ButtonGroup>
|
|
|
|
{/* Dialogs */}
|
|
<LinkDialog
|
|
open={linkDialogOpen}
|
|
onClose={() => setLinkDialogOpen(false)}
|
|
onInsert={(url, text) => {
|
|
editor.chain().focus().setLink({ href: url }).insertContent(text).run()
|
|
}}
|
|
/>
|
|
|
|
<ImageDialog
|
|
open={imageDialogOpen}
|
|
onClose={() => setImageDialogOpen(false)}
|
|
onInsert={(url, alt) => {
|
|
editor.chain().focus().setImage({ src: url, alt }).run()
|
|
}}
|
|
/>
|
|
|
|
<VerseReferenceDialog
|
|
open={verseRefDialogOpen}
|
|
onClose={() => setVerseRefDialogOpen(false)}
|
|
onInsert={(ref) => {
|
|
editor.chain().focus().insertContent({
|
|
type: 'verseReference',
|
|
attrs: {
|
|
book: ref.book,
|
|
chapter: ref.chapter,
|
|
verse: ref.verse,
|
|
text: `${ref.book} ${ref.chapter}:${ref.verse}`
|
|
}
|
|
}).run()
|
|
}}
|
|
/>
|
|
</Box>
|
|
)
|
|
}
|
|
```
|
|
|
|
### 4. Notes List & Organization
|
|
|
|
```typescript
|
|
const NotesPage: React.FC = () => {
|
|
const [notes, setNotes] = useState<StudyNote[]>([])
|
|
const [folders, setFolders] = useState<NoteFolder[]>([])
|
|
const [selectedFolder, setSelectedFolder] = useState<string | null>(null)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [sortBy, setSortBy] = useState<'updated' | 'created' | 'title'>('updated')
|
|
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'compact'>('list')
|
|
|
|
// Load notes
|
|
useEffect(() => {
|
|
loadNotes()
|
|
}, [selectedFolder, searchQuery, sortBy])
|
|
|
|
const loadNotes = async () => {
|
|
const params = new URLSearchParams({
|
|
...(selectedFolder && { folderId: selectedFolder }),
|
|
...(searchQuery && { search: searchQuery }),
|
|
sortBy
|
|
})
|
|
|
|
const response = await fetch(`/api/notes?${params}`)
|
|
const data = await response.json()
|
|
setNotes(data.notes)
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', height: '100vh' }}>
|
|
{/* Sidebar - Folders */}
|
|
<Box sx={{ width: 250, borderRight: 1, borderColor: 'divider', p: 2 }}>
|
|
<Typography variant="h6" gutterBottom>
|
|
Study Notes
|
|
</Typography>
|
|
|
|
<Button
|
|
fullWidth
|
|
variant="contained"
|
|
startIcon={<AddIcon />}
|
|
onClick={() => createNewNote()}
|
|
sx={{ mb: 2 }}
|
|
>
|
|
New Note
|
|
</Button>
|
|
|
|
<List>
|
|
<ListItem
|
|
button
|
|
selected={selectedFolder === null}
|
|
onClick={() => setSelectedFolder(null)}
|
|
>
|
|
<ListItemIcon><AllInboxIcon /></ListItemIcon>
|
|
<ListItemText primary="All Notes" secondary={notes.length} />
|
|
</ListItem>
|
|
|
|
<ListItem button>
|
|
<ListItemIcon><StarIcon /></ListItemIcon>
|
|
<ListItemText primary="Favorites" />
|
|
</ListItem>
|
|
|
|
<Divider sx={{ my: 1 }} />
|
|
|
|
<ListSubheader>Folders</ListSubheader>
|
|
|
|
{folders.map(folder => (
|
|
<ListItem
|
|
key={folder.id}
|
|
button
|
|
selected={selectedFolder === folder.id}
|
|
onClick={() => setSelectedFolder(folder.id)}
|
|
sx={{ pl: 3 }}
|
|
>
|
|
<ListItemIcon>
|
|
<FolderIcon style={{ color: folder.color }} />
|
|
</ListItemIcon>
|
|
<ListItemText primary={folder.name} />
|
|
</ListItem>
|
|
))}
|
|
|
|
<ListItem button onClick={() => createFolder()}>
|
|
<ListItemIcon><AddIcon /></ListItemIcon>
|
|
<ListItemText primary="New Folder" />
|
|
</ListItem>
|
|
</List>
|
|
</Box>
|
|
|
|
{/* Main Content - Notes List */}
|
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
|
{/* Toolbar */}
|
|
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
|
<Box display="flex" gap={2} alignItems="center">
|
|
<TextField
|
|
placeholder="Search notes..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
size="small"
|
|
fullWidth
|
|
InputProps={{
|
|
startAdornment: <SearchIcon />
|
|
}}
|
|
/>
|
|
|
|
<FormControl size="small" sx={{ minWidth: 120 }}>
|
|
<Select value={sortBy} onChange={(e) => setSortBy(e.target.value as any)}>
|
|
<MenuItem value="updated">Last Updated</MenuItem>
|
|
<MenuItem value="created">Date Created</MenuItem>
|
|
<MenuItem value="title">Title</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<ToggleButtonGroup
|
|
value={viewMode}
|
|
exclusive
|
|
onChange={(_, value) => value && setViewMode(value)}
|
|
size="small"
|
|
>
|
|
<ToggleButton value="list"><ViewListIcon /></ToggleButton>
|
|
<ToggleButton value="grid"><ViewModuleIcon /></ToggleButton>
|
|
<ToggleButton value="compact"><ViewHeadlineIcon /></ToggleButton>
|
|
</ToggleButtonGroup>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Notes Display */}
|
|
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
|
|
{viewMode === 'grid' ? (
|
|
<Grid container spacing={2}>
|
|
{notes.map(note => (
|
|
<Grid item key={note.id} xs={12} sm={6} md={4}>
|
|
<NoteCard note={note} onClick={() => openNote(note)} />
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
) : (
|
|
<List>
|
|
{notes.map(note => (
|
|
<NoteListItem
|
|
key={note.id}
|
|
note={note}
|
|
compact={viewMode === 'compact'}
|
|
onClick={() => openNote(note)}
|
|
/>
|
|
))}
|
|
</List>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|
|
```
|
|
|
|
### 5. Note Templates
|
|
|
|
```typescript
|
|
const NOTE_TEMPLATES = [
|
|
{
|
|
id: 'sermon-notes',
|
|
name: 'Sermon Notes',
|
|
icon: '📝',
|
|
content: `
|
|
<h1>Sermon Notes</h1>
|
|
<p><strong>Date:</strong> </p>
|
|
<p><strong>Speaker:</strong> </p>
|
|
<p><strong>Topic:</strong> </p>
|
|
<h2>Main Points</h2>
|
|
<ol>
|
|
<li></li>
|
|
<li></li>
|
|
<li></li>
|
|
</ol>
|
|
<h2>Key Verses</h2>
|
|
<p></p>
|
|
<h2>Personal Application</h2>
|
|
<p></p>
|
|
<h2>Prayer Points</h2>
|
|
<ul>
|
|
<li></li>
|
|
</ul>
|
|
`
|
|
},
|
|
{
|
|
id: 'bible-study',
|
|
name: 'Bible Study',
|
|
icon: '📖',
|
|
content: `
|
|
<h1>Bible Study</h1>
|
|
<h2>Passage</h2>
|
|
<p></p>
|
|
<h2>Context</h2>
|
|
<p><strong>Historical Context:</strong> </p>
|
|
<p><strong>Literary Context:</strong> </p>
|
|
<h2>Observation</h2>
|
|
<ul>
|
|
<li>What does the text say?</li>
|
|
</ul>
|
|
<h2>Interpretation</h2>
|
|
<ul>
|
|
<li>What does it mean?</li>
|
|
</ul>
|
|
<h2>Application</h2>
|
|
<ul>
|
|
<li>How does this apply to my life?</li>
|
|
</ul>
|
|
`
|
|
},
|
|
{
|
|
id: 'character-study',
|
|
name: 'Character Study',
|
|
icon: '👤',
|
|
content: `
|
|
<h1>Character Study: [Name]</h1>
|
|
<h2>Background</h2>
|
|
<p><strong>Family:</strong> </p>
|
|
<p><strong>Occupation:</strong> </p>
|
|
<p><strong>Time Period:</strong> </p>
|
|
<h2>Key Events</h2>
|
|
<ol>
|
|
<li></li>
|
|
</ol>
|
|
<h2>Character Traits</h2>
|
|
<ul>
|
|
<li><strong>Strengths:</strong> </li>
|
|
<li><strong>Weaknesses:</strong> </li>
|
|
</ul>
|
|
<h2>Lessons Learned</h2>
|
|
<p></p>
|
|
`
|
|
},
|
|
{
|
|
id: 'topical-study',
|
|
name: 'Topical Study',
|
|
icon: '🏷️',
|
|
content: `
|
|
<h1>Topical Study: [Topic]</h1>
|
|
<h2>Definition</h2>
|
|
<p></p>
|
|
<h2>Key Verses</h2>
|
|
<ul>
|
|
<li></li>
|
|
</ul>
|
|
<h2>What the Bible Says</h2>
|
|
<p></p>
|
|
<h2>Practical Application</h2>
|
|
<p></p>
|
|
`
|
|
}
|
|
]
|
|
|
|
const TemplateSelector: React.FC<{
|
|
onSelect: (template: string) => void
|
|
}> = ({ onSelect }) => {
|
|
return (
|
|
<Grid container spacing={2}>
|
|
{NOTE_TEMPLATES.map(template => (
|
|
<Grid item key={template.id} xs={12} sm={6} md={4}>
|
|
<Card
|
|
sx={{ cursor: 'pointer', '&:hover': { boxShadow: 4 } }}
|
|
onClick={() => onSelect(template.content)}
|
|
>
|
|
<CardContent>
|
|
<Typography variant="h4" textAlign="center" mb={1}>
|
|
{template.icon}
|
|
</Typography>
|
|
<Typography variant="h6" textAlign="center">
|
|
{template.name}
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
)
|
|
}
|
|
```
|
|
|
|
### 6. Full-Text Search
|
|
|
|
```typescript
|
|
// API endpoint with PostgreSQL full-text search
|
|
export async function POST(request: Request) {
|
|
const { query } = await request.json()
|
|
const userId = await getUserIdFromAuth(request)
|
|
|
|
const notes = await prisma.$queryRaw`
|
|
SELECT
|
|
id,
|
|
title,
|
|
"plainText",
|
|
ts_rank(to_tsvector('english', title || ' ' || "plainText"), plainto_tsquery('english', ${query})) AS rank
|
|
FROM "StudyNote"
|
|
WHERE
|
|
"userId" = ${userId}
|
|
AND to_tsvector('english', title || ' ' || "plainText") @@ plainto_tsquery('english', ${query})
|
|
ORDER BY rank DESC
|
|
LIMIT 50
|
|
`
|
|
|
|
return NextResponse.json({ notes })
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🗄️ Database Schema
|
|
|
|
```prisma
|
|
model StudyNote {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id])
|
|
|
|
title String
|
|
content String @db.Text
|
|
contentType String @default("html")
|
|
plainText String @db.Text // For search
|
|
|
|
folderId String?
|
|
folder NoteFolder? @relation(fields: [folderId], references: [id])
|
|
|
|
tags String[]
|
|
color String?
|
|
isPinned Boolean @default(false)
|
|
isFavorite Boolean @default(false)
|
|
|
|
visibility String @default("private")
|
|
sharedWith String[]
|
|
|
|
wordCount Int @default(0)
|
|
readingTime Int @default(0)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
lastViewedAt DateTime @default(now())
|
|
version Int @default(1)
|
|
|
|
verseReferences NoteVerseReference[]
|
|
|
|
@@index([userId, updatedAt])
|
|
@@index([userId, folderId])
|
|
@@index([userId, isPinned])
|
|
}
|
|
|
|
model NoteFolder {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id])
|
|
|
|
name String
|
|
description String?
|
|
parentId String?
|
|
parent NoteFolder? @relation("FolderHierarchy", fields: [parentId], references: [id])
|
|
children NoteFolder[] @relation("FolderHierarchy")
|
|
|
|
color String @default("#1976d2")
|
|
icon String @default("folder")
|
|
order Int @default(0)
|
|
|
|
notes StudyNote[]
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([userId, parentId])
|
|
}
|
|
|
|
model NoteVerseReference {
|
|
id String @id @default(cuid())
|
|
noteId String
|
|
note StudyNote @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
|
|
|
book String
|
|
chapter Int
|
|
verse Int
|
|
endVerse Int?
|
|
context String?
|
|
|
|
@@index([noteId])
|
|
@@index([book, chapter, verse])
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📅 Implementation Timeline
|
|
|
|
### Week 1
|
|
**Day 1-2:** Setup & Editor
|
|
- [ ] Create database schema
|
|
- [ ] Set up TipTap editor
|
|
- [ ] Build basic toolbar
|
|
|
|
**Day 3-4:** Core Features
|
|
- [ ] Implement save/autosave
|
|
- [ ] Add formatting options
|
|
- [ ] Build media insertion
|
|
|
|
**Day 5:** Organization
|
|
- [ ] Create folders system
|
|
- [ ] Add tags support
|
|
- [ ] Implement search
|
|
|
|
### Week 2
|
|
**Day 1-2:** Advanced Features
|
|
- [ ] Build templates
|
|
- [ ] Add verse references
|
|
- [ ] Implement version history
|
|
|
|
**Day 3-4:** Polish
|
|
- [ ] Mobile optimization
|
|
- [ ] Performance tuning
|
|
- [ ] UI refinement
|
|
|
|
**Day 5:** Testing & Launch
|
|
- [ ] Bug fixes
|
|
- [ ] Documentation
|
|
- [ ] Deployment
|
|
|
|
---
|
|
|
|
**Document Version:** 1.0
|
|
**Last Updated:** 2025-10-13
|
|
**Status:** Ready for Implementation
|