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>
23 KiB
23 KiB
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
- Provide rich text editing capabilities for study notes
- Enable advanced formatting (bold, italic, lists, headers)
- Support multimedia content (images, links, videos)
- Organize notes with folders and tags
- 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
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)
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
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
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
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
// 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
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