Files
biblical-guide.com/RICH_TEXT_NOTES_PLAN.md
Andrei 9b5c0ed8bb 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>
2025-11-11 20:38:01 +00:00

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

  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

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>
  )
}
// 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