build: production build with Phase 1 2025 Bible Reader implementation complete

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>
This commit is contained in:
2025-11-11 20:38:01 +00:00
parent b8652b9f0a
commit 9b5c0ed8bb
50 changed files with 20146 additions and 859 deletions

866
RICH_TEXT_NOTES_PLAN.md Normal file
View File

@@ -0,0 +1,866 @@
# 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