feat: Add rich text formatting to sermon notes

Implement comprehensive rich text editing capabilities using Tiptap editor with full formatting toolbar and functionality.

Features:
- Bold, italic, underline, strikethrough text formatting
- Highlight text with yellow marker
- Bullet and numbered lists
- Heading styles (H2, H3)
- Text alignment (left, center, right)
- Undo/redo support
- Clear formatting option
- Intuitive toolbar with icons and tooltips
- Responsive font size support

Technical changes:
- Added Tiptap dependencies to package.json (@tiptap/vue-3, starter-kit, extensions)
- Created RichTextEditor component with full toolbar and formatting options
- Integrated editor into sermon notes section replacing textarea
- Updated download API to convert HTML to formatted plain text
- Updated email API to handle HTML content properly
- Notes now stored as HTML with rich formatting preserved

The editor provides a professional writing experience with all standard formatting tools while maintaining automatic save functionality and seamless integration with email/download features.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-07 09:47:23 -05:00
parent 3f1c573a67
commit 3a50cbebdd
5 changed files with 340 additions and 10 deletions

View File

@@ -0,0 +1,309 @@
<template>
<div class="rich-text-editor border border-gray-300 rounded-lg shadow-sm overflow-hidden" :class="editorClass">
<!-- Toolbar -->
<div v-if="editor" class="toolbar bg-gray-50 border-b border-gray-300 p-2 flex flex-wrap gap-1">
<!-- Undo/Redo -->
<button
@click="editor.chain().focus().undo().run()"
:disabled="!editor.can().undo()"
:class="toolbarButtonClass"
title="Undo (Ctrl+Z)"
type="button"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
<button
@click="editor.chain().focus().redo().run()"
:disabled="!editor.can().redo()"
:class="toolbarButtonClass"
title="Redo (Ctrl+Y)"
type="button"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 10H11a8 8 0 00-8 8v2m18-10l-6 6m6-6l-6-6" />
</svg>
</button>
<div class="w-px h-6 bg-gray-300 mx-1"></div>
<!-- Text Formatting -->
<button
@click="editor.chain().focus().toggleBold().run()"
:class="[toolbarButtonClass, editor.isActive('bold') ? 'bg-blue-100 text-blue-700' : '']"
title="Bold (Ctrl+B)"
type="button"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/>
</svg>
</button>
<button
@click="editor.chain().focus().toggleItalic().run()"
:class="[toolbarButtonClass, editor.isActive('italic') ? 'bg-blue-100 text-blue-700' : '']"
title="Italic (Ctrl+I)"
type="button"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4h-8z"/>
</svg>
</button>
<button
@click="editor.chain().focus().toggleUnderline().run()"
:class="[toolbarButtonClass, editor.isActive('underline') ? 'bg-blue-100 text-blue-700' : '']"
title="Underline (Ctrl+U)"
type="button"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z"/>
</svg>
</button>
<button
@click="editor.chain().focus().toggleStrike().run()"
:class="[toolbarButtonClass, editor.isActive('strike') ? 'bg-blue-100 text-blue-700' : '']"
title="Strikethrough"
type="button"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"/>
</svg>
</button>
<div class="w-px h-6 bg-gray-300 mx-1"></div>
<!-- Highlight -->
<button
@click="editor.chain().focus().toggleHighlight().run()"
:class="[toolbarButtonClass, editor.isActive('highlight') ? 'bg-yellow-100 text-yellow-700' : '']"
title="Highlight"
type="button"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.75 7L14 3.25l-10 10V17h3.75l10-10zm2.96-2.96c.39-.39.39-1.02 0-1.41L18.37.29c-.39-.39-1.02-.39-1.41 0L15 2.25 18.75 6l1.96-1.96z"/>
<path d="M0 20h24v4H0z"/>
</svg>
</button>
<div class="w-px h-6 bg-gray-300 mx-1"></div>
<!-- Lists -->
<button
@click="editor.chain().focus().toggleBulletList().run()"
:class="[toolbarButtonClass, editor.isActive('bulletList') ? 'bg-blue-100 text-blue-700' : '']"
title="Bullet List"
type="button"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/>
</svg>
</button>
<button
@click="editor.chain().focus().toggleOrderedList().run()"
:class="[toolbarButtonClass, editor.isActive('orderedList') ? 'bg-blue-100 text-blue-700' : '']"
title="Numbered List"
type="button"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z"/>
</svg>
</button>
<div class="w-px h-6 bg-gray-300 mx-1"></div>
<!-- Headings -->
<button
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
:class="[toolbarButtonClass, editor.isActive('heading', { level: 2 }) ? 'bg-blue-100 text-blue-700' : '']"
title="Heading 2"
type="button"
>
<span class="font-bold text-sm">H2</span>
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
:class="[toolbarButtonClass, editor.isActive('heading', { level: 3 }) ? 'bg-blue-100 text-blue-700' : '']"
title="Heading 3"
type="button"
>
<span class="font-bold text-sm">H3</span>
</button>
<div class="w-px h-6 bg-gray-300 mx-1"></div>
<!-- Alignment -->
<button
@click="editor.chain().focus().setTextAlign('left').run()"
:class="[toolbarButtonClass, editor.isActive({ textAlign: 'left' }) ? 'bg-blue-100 text-blue-700' : '']"
title="Align Left"
type="button"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z"/>
</svg>
</button>
<button
@click="editor.chain().focus().setTextAlign('center').run()"
:class="[toolbarButtonClass, editor.isActive({ textAlign: 'center' }) ? 'bg-blue-100 text-blue-700' : '']"
title="Align Center"
type="button"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z"/>
</svg>
</button>
<button
@click="editor.chain().focus().setTextAlign('right').run()"
:class="[toolbarButtonClass, editor.isActive({ textAlign: 'right' }) ? 'bg-blue-100 text-blue-700' : '']"
title="Align Right"
type="button"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 21h18v-2H3v2zm6-4h12v-2H9v2zm-6-4h18v-2H3v2zm6-4h12V7H9v2zM3 3v2h18V3H3z"/>
</svg>
</button>
<div class="w-px h-6 bg-gray-300 mx-1"></div>
<!-- Clear Formatting -->
<button
@click="editor.chain().focus().clearNodes().unsetAllMarks().run()"
:class="toolbarButtonClass"
title="Clear Formatting"
type="button"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 5v.18L8.82 8h2.4l-.72 1.68 2.1 2.1L14.21 8H20V5H6zm14 14l-1.41-1.41-5.82-5.82L7.76 6.76 6.41 5.41 5 6.82l2.23 2.23-1.61 3.73L1 17.43V21h3.57l4.66-4.66 5.73 5.73L16.41 23 18 21.41z"/>
</svg>
</button>
</div>
<!-- Editor Content -->
<EditorContent :editor="editor" class="prose max-w-none p-4 min-h-[16rem] focus:outline-none" />
</div>
</template>
<script setup lang="ts">
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Highlight from '@tiptap/extension-highlight'
import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
const props = defineProps<{
modelValue: string
editorClass?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const toolbarButtonClass = computed(() =>
'p-2 rounded hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition-colors'
)
const editor = useEditor({
extensions: [
StarterKit,
Highlight,
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
],
content: props.modelValue,
onUpdate: ({ editor }) => {
emit('update:modelValue', editor.getHTML())
},
editorProps: {
attributes: {
class: 'prose max-w-none focus:outline-none',
},
},
})
// Watch for external changes to modelValue
watch(() => props.modelValue, (newValue) => {
if (editor.value && newValue !== editor.value.getHTML()) {
editor.value.commands.setContent(newValue, false)
}
})
onBeforeUnmount(() => {
editor.value?.destroy()
})
</script>
<style>
/* Tiptap Editor Styles */
.ProseMirror {
outline: none;
min-height: 16rem;
}
.ProseMirror p {
margin: 0.5rem 0;
}
.ProseMirror h2 {
font-size: 1.5rem;
font-weight: 700;
margin: 1rem 0 0.5rem;
}
.ProseMirror h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0.75rem 0 0.5rem;
}
.ProseMirror ul,
.ProseMirror ol {
padding-left: 1.5rem;
margin: 0.5rem 0;
}
.ProseMirror ul {
list-style-type: disc;
}
.ProseMirror ol {
list-style-type: decimal;
}
.ProseMirror li {
margin: 0.25rem 0;
}
.ProseMirror mark {
background-color: #fef08a;
padding: 0.125rem 0.25rem;
border-radius: 0.125rem;
}
.ProseMirror strong {
font-weight: 700;
}
.ProseMirror em {
font-style: italic;
}
.ProseMirror u {
text-decoration: underline;
}
.ProseMirror s {
text-decoration: line-through;
}
/* Placeholder */
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: #9ca3af;
pointer-events: none;
height: 0;
}
</style>

View File

@@ -11,6 +11,11 @@
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"dependencies": { "dependencies": {
"@tiptap/extension-highlight": "^2.10.3",
"@tiptap/extension-text-align": "^2.10.3",
"@tiptap/extension-underline": "^2.10.3",
"@tiptap/starter-kit": "^2.10.3",
"@tiptap/vue-3": "^2.10.3",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"nodemailer": "^6.9.7", "nodemailer": "^6.9.7",

View File

@@ -117,13 +117,11 @@
<p class="mb-3 text-sm text-red-600 font-medium"> <p class="mb-3 text-sm text-red-600 font-medium">
Notes are deleted if a Sermon is deleted by an admin. Please email or download notes to have a copy sent to you. Notes are deleted if a Sermon is deleted by an admin. Please email or download notes to have a copy sent to you.
</p> </p>
<textarea <RichTextEditor
v-model="notes" v-model="notes"
@input="handleNotesChange" @update:modelValue="handleNotesChange"
placeholder="Take notes during the sermon..." :editorClass="fontSizeClasses"
class="w-full h-64 px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-y" />
:class="fontSizeClasses"
></textarea>
<div class="mt-2 flex items-center justify-between"> <div class="mt-2 flex items-center justify-between">
<p v-if="saveStatus" class="text-sm" :class="saveStatus === 'Saved' ? 'text-green-600' : 'text-gray-500'"> <p v-if="saveStatus" class="text-sm" :class="saveStatus === 'Saved' ? 'text-green-600' : 'text-gray-500'">
{{ saveStatus }} {{ saveStatus }}

View File

@@ -42,7 +42,26 @@ export default defineEventHandler(async (event) => {
// Get user's notes // Get user's notes
const noteRecord = getSermonNote(user.id!, sermonId) const noteRecord = getSermonNote(user.id!, sermonId)
const userNotes = noteRecord?.notes || 'No notes taken' // Convert HTML to plain text for download
const htmlToText = (html: string) => {
if (!html) return 'No notes taken'
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n')
.replace(/<p[^>]*>/gi, '')
.replace(/<\/h[1-6]>/gi, '\n')
.replace(/<h[1-6][^>]*>/gi, '')
.replace(/<li[^>]*>/gi, '• ')
.replace(/<\/li>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.trim()
}
const userNotes = htmlToText(noteRecord?.notes || '')
// Format bible references // Format bible references
let bibleReferencesText = '' let bibleReferencesText = ''

View File

@@ -41,10 +41,9 @@ export default defineEventHandler(async (event) => {
}) })
} }
// Get user's notes // Get user's notes (already stored as HTML from rich text editor)
const noteRecord = getSermonNote(user.id!, sermonId) const noteRecord = getSermonNote(user.id!, sermonId)
// Convert line breaks to HTML breaks for email display const userNotes = noteRecord?.notes || '<p>No notes taken</p>'
const userNotes = noteRecord?.notes ? noteRecord.notes.replace(/\n/g, '<br>') : ''
// Format bible references for HTML email // Format bible references for HTML email
let bibleReferencesText = '' let bibleReferencesText = ''