Notes: multiple notes per section with last-edited dates, delete, sort by recently edited; Word export now readable; shared note-format module + tests; v2.43.0
Publish Container / build (push) Successful in 16m3s
Publish Container / build (push) Successful in 16m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
401fd451de
commit
09177f47ed
8 files changed
+285
-76
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.42.2",
|
"version": "2.43.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { noteEntriesToText } from '../../shared/note-format.js'
|
||||||
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
||||||
import rateLimit from 'express-rate-limit'
|
import rateLimit from 'express-rate-limit'
|
||||||
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
|
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
|
||||||
@@ -87,7 +88,7 @@ export function register(app) {
|
|||||||
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
|
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
|
||||||
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
|
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
|
||||||
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
|
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
|
||||||
studyEntries[studySlug][sectionId].noteText = noteText.trim()
|
studyEntries[studySlug][sectionId].noteText = noteEntriesToText(noteText) || noteText.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
|
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
|
||||||
|
|||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
export type NoteAnchorId = 'scripture' | 'commentary' | 'questions' | 'general'
|
||||||
|
export interface NoteEntry {
|
||||||
|
id: string
|
||||||
|
anchor: NoteAnchorId
|
||||||
|
html: string
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
export const NOTE_ANCHORS: NoteAnchorId[]
|
||||||
|
export const NOTE_ANCHOR_LABELS: Record<NoteAnchorId, string>
|
||||||
|
export function newNoteId(): string
|
||||||
|
export function parseNoteEntries(raw: string): NoteEntry[]
|
||||||
|
export function serializeNoteEntries(entries: NoteEntry[]): string
|
||||||
|
export function noteHtmlToText(html: string): string
|
||||||
|
export function noteEntriesToText(raw: string): string
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Lesson note storage format, shared by the client and the server.
|
||||||
|
//
|
||||||
|
// Each lesson's notes are stored as one string. Current format (v2):
|
||||||
|
// {"v":2,"entries":[{"id","anchor","html","updatedAt"}]}
|
||||||
|
// Legacy format (v1) was a JSON object of { anchor: html }, and older still was
|
||||||
|
// plain text. Both are still read and are upgraded to v2 on the next save.
|
||||||
|
|
||||||
|
export const NOTE_ANCHORS = ['scripture', 'commentary', 'questions', 'general']
|
||||||
|
|
||||||
|
export const NOTE_ANCHOR_LABELS = {
|
||||||
|
scripture: 'Scripture Text',
|
||||||
|
commentary: 'Commentary',
|
||||||
|
questions: 'Reflection Questions',
|
||||||
|
general: 'General Notes',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newNoteId() {
|
||||||
|
return `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNoteEntries(raw) {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return []
|
||||||
|
let parsed = null
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
// Legacy plain-text note.
|
||||||
|
return [{ id: newNoteId(), anchor: 'general', html: raw, updatedAt: null }]
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return [{ id: newNoteId(), anchor: 'general', html: raw, updatedAt: null }]
|
||||||
|
}
|
||||||
|
if (parsed.v === 2 && Array.isArray(parsed.entries)) {
|
||||||
|
return parsed.entries
|
||||||
|
.filter(e => e && typeof e.html === 'string' && e.html.trim())
|
||||||
|
.map(e => ({
|
||||||
|
id: typeof e.id === 'string' && e.id ? e.id : newNoteId(),
|
||||||
|
anchor: NOTE_ANCHORS.includes(e.anchor) ? e.anchor : 'general',
|
||||||
|
html: e.html,
|
||||||
|
updatedAt: typeof e.updatedAt === 'string' ? e.updatedAt : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return Object.entries(parsed)
|
||||||
|
.filter(([, html]) => typeof html === 'string' && html.trim())
|
||||||
|
.map(([anchor, html]) => ({
|
||||||
|
id: `legacy-${anchor}`,
|
||||||
|
anchor: NOTE_ANCHORS.includes(anchor) ? anchor : 'general',
|
||||||
|
html,
|
||||||
|
updatedAt: null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeNoteEntries(entries) {
|
||||||
|
const kept = entries.filter(e => e.html && e.html.trim())
|
||||||
|
if (kept.length === 0) return ''
|
||||||
|
return JSON.stringify({ v: 2, entries: kept })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noteHtmlToText(html) {
|
||||||
|
return String(html)
|
||||||
|
.replace(/<\/(p|li|h[1-6]|blockquote)>/gi, '\n')
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<[^>]*>/g, '')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain-text rendering of a whole lesson's notes (used by the Word export).
|
||||||
|
export function noteEntriesToText(raw) {
|
||||||
|
return parseNoteEntries(raw)
|
||||||
|
.map(e => `${NOTE_ANCHOR_LABELS[e.anchor]}\n${noteHtmlToText(e.html)}`)
|
||||||
|
.join('\n\n')
|
||||||
|
}
|
||||||
+16
@@ -14165,3 +14165,19 @@ a.sl-pager-link:hover { border-color: rgba(201, 168, 76, 0.6); transform: transl
|
|||||||
|
|
||||||
.notes-other-matches { margin-top: 1rem; }
|
.notes-other-matches { margin-top: 1rem; }
|
||||||
.notes-export-btn { margin-top: 1rem; width: 100%; }
|
.notes-export-btn { margin-top: 1rem; width: 100%; }
|
||||||
|
|
||||||
|
.note-card-footer { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; }
|
||||||
|
.note-card-delete { background: none; border: 0; padding: 0; color: #b5654f; cursor: pointer; font: inherit; }
|
||||||
|
.note-card-delete:hover { text-decoration: underline; }
|
||||||
|
.note-entry-list { list-style: none; margin: 0 0 0.75rem; padding: 0; display: grid; gap: 0.4rem; }
|
||||||
|
.note-entry { width: 100%; text-align: left; background: rgba(201, 168, 76, 0.06); border: 1px solid rgba(201, 168, 76, 0.18); border-radius: 8px; padding: 0.45rem 0.65rem; cursor: pointer; color: inherit; font: inherit; display: grid; gap: 0.1rem; }
|
||||||
|
.note-entry:hover, .note-entry--active { border-color: #c9a84c; background: rgba(201, 168, 76, 0.14); }
|
||||||
|
.note-entry-label { font-size: 0.72rem; color: #c9a84c; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.note-entry-snippet { font-size: 0.85rem; color: #b8ad92; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.note-add-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-top: 0.75rem; font-size: 0.8rem; color: #7a7060; }
|
||||||
|
.note-add-chip { background: none; border: 1px solid rgba(201, 168, 76, 0.35); border-radius: 999px; padding: 0.2rem 0.6rem; color: #c9a84c; cursor: pointer; font: inherit; font-size: 0.78rem; }
|
||||||
|
.note-add-chip:hover { background: rgba(201, 168, 76, 0.12); }
|
||||||
|
.notes-sort { display: flex; gap: 0.4rem; margin: 0.6rem 0; }
|
||||||
|
.notes-sort-btn { flex: 1; background: none; border: 1px solid rgba(201, 168, 76, 0.25); border-radius: 6px; padding: 0.3rem 0.4rem; color: #b8ad92; cursor: pointer; font: inherit; font-size: 0.78rem; }
|
||||||
|
.notes-sort-btn--active { border-color: #c9a84c; color: #c9a84c; background: rgba(201, 168, 76, 0.1); }
|
||||||
|
.notes-block-date { font-weight: 400; color: #7a7060; font-size: 0.75rem; }
|
||||||
@@ -5,12 +5,14 @@ import { useEffect } from 'react'
|
|||||||
interface NoteCardProps {
|
interface NoteCardProps {
|
||||||
anchorLabel: string
|
anchorLabel: string
|
||||||
html: string
|
html: string
|
||||||
|
updatedAt?: string | null
|
||||||
|
onDelete?: () => void
|
||||||
autoSaveMsg?: string
|
autoSaveMsg?: string
|
||||||
onChange: (html: string) => void
|
onChange: (html: string) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }: NoteCardProps) {
|
export function NoteCard({ anchorLabel, html, updatedAt, onDelete, autoSaveMsg, onChange, onClose }: NoteCardProps) {
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [StarterKit],
|
extensions: [StarterKit],
|
||||||
shouldRerenderOnTransaction: true,
|
shouldRerenderOnTransaction: true,
|
||||||
@@ -115,7 +117,19 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditorContent editor={editor} className="note-card-editor" />
|
<EditorContent editor={editor} className="note-card-editor" />
|
||||||
<div className="note-card-footer">{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}</div>
|
<div className="note-card-footer">
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="note-card-delete"
|
||||||
|
onClick={() => { if (window.confirm('Delete this note?')) onDelete() }}
|
||||||
|
>Delete note</button>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{updatedAt ? `Edited ${new Date(updatedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} · ` : ''}
|
||||||
|
{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+125
-72
@@ -9,6 +9,8 @@ import { Breadcrumbs } from './components/Breadcrumbs'
|
|||||||
import { StudyCertificate } from './components/StudyCertificate'
|
import { StudyCertificate } from './components/StudyCertificate'
|
||||||
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
|
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
|
||||||
import { NoteCard } from './components/NoteCard'
|
import { NoteCard } from './components/NoteCard'
|
||||||
|
import { NOTE_ANCHORS, NOTE_ANCHOR_LABELS, newNoteId, parseNoteEntries, serializeNoteEntries, noteHtmlToText } from '../shared/note-format.js'
|
||||||
|
import type { NoteAnchorId, NoteEntry } from '../shared/note-format.js'
|
||||||
import { StudySectionComments } from './components/StudySectionComments'
|
import { StudySectionComments } from './components/StudySectionComments'
|
||||||
|
|
||||||
type Props = { content: SiteContent }
|
type Props = { content: SiteContent }
|
||||||
@@ -248,27 +250,8 @@ function getNoteId(studySlug: string, sectionId: string) {
|
|||||||
return `${studySlug}--${sectionId}`
|
return `${studySlug}--${sectionId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NoteAnchorId = 'scripture' | 'commentary' | 'questions' | 'general'
|
const ANCHOR_LABELS: Record<string, string> = NOTE_ANCHOR_LABELS
|
||||||
|
|
||||||
const ANCHOR_LABELS: Record<string, string> = {
|
|
||||||
scripture: 'Scripture Text',
|
|
||||||
commentary: 'Commentary',
|
|
||||||
questions: 'Reflection Questions',
|
|
||||||
general: 'General Notes',
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseNoteMap(raw: string): Record<string, string> {
|
|
||||||
if (!raw?.trim()) return {}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
||||||
return parsed as Record<string, string>
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Not valid JSON — treat the raw string as legacy plain-text notes.
|
|
||||||
}
|
|
||||||
return { general: raw }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayName(auth: StudyAuthState) {
|
function getDisplayName(auth: StudyAuthState) {
|
||||||
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
|
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
|
||||||
@@ -1866,8 +1849,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
const [enrollBusy, setEnrollBusy] = useState(false)
|
const [enrollBusy, setEnrollBusy] = useState(false)
|
||||||
const [enrollMessage, setEnrollMessage] = useState('')
|
const [enrollMessage, setEnrollMessage] = useState('')
|
||||||
|
|
||||||
const [noteMap, setNoteMap] = useState<Record<string, string>>({})
|
const [noteEntries, setNoteEntries] = useState<NoteEntry[]>([])
|
||||||
const [openAnchor, setOpenAnchor] = useState<NoteAnchorId | null>(null)
|
const [activeNoteId, setActiveNoteId] = useState<string | null>(null)
|
||||||
const [noteLoading, setNoteLoading] = useState(false)
|
const [noteLoading, setNoteLoading] = useState(false)
|
||||||
const [noteMessage, setNoteMessage] = useState('')
|
const [noteMessage, setNoteMessage] = useState('')
|
||||||
const noteMapDirtyRef = useRef(false)
|
const noteMapDirtyRef = useRef(false)
|
||||||
@@ -1886,7 +1869,11 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
const [bsbPassage, setBsbPassage] = useState<string | null>(null)
|
const [bsbPassage, setBsbPassage] = useState<string | null>(null)
|
||||||
const [bsbPassageLoading, setBsbPassageLoading] = useState(false)
|
const [bsbPassageLoading, setBsbPassageLoading] = useState(false)
|
||||||
|
|
||||||
const savedNoteCount = Object.values(noteMap).filter(v => v?.trim()).length
|
const activeEntry = noteEntries.find(e => e.id === activeNoteId) ?? null
|
||||||
|
const openAnchor: NoteAnchorId | null = activeEntry?.anchor ?? null
|
||||||
|
const savedEntries = noteEntries.filter(e => e.html.trim())
|
||||||
|
const savedNoteCount = savedEntries.length
|
||||||
|
const hasNoteOn = (anchor: NoteAnchorId) => savedEntries.some(e => e.anchor === anchor)
|
||||||
const audioEmbedTrimmed = section?.audioEmbedUrl?.trim() ?? ''
|
const audioEmbedTrimmed = section?.audioEmbedUrl?.trim() ?? ''
|
||||||
const lessonAudioEmbedUrl = /^https?:\/\//i.test(audioEmbedTrimmed) ? audioEmbedTrimmed : ''
|
const lessonAudioEmbedUrl = /^https?:\/\//i.test(audioEmbedTrimmed) ? audioEmbedTrimmed : ''
|
||||||
|
|
||||||
@@ -1969,7 +1956,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth.authenticated || !noteId || !isEnrolled) {
|
if (!auth.authenticated || !noteId || !isEnrolled) {
|
||||||
setNoteMap({})
|
setNoteEntries([])
|
||||||
|
setActiveNoteId(null)
|
||||||
noteMapDirtyRef.current = false
|
noteMapDirtyRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1981,7 +1969,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
readJson<{ note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`)
|
readJson<{ note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setNoteMap(parseNoteMap(data.note ?? ''))
|
setNoteEntries(parseNoteEntries(data.note ?? ''))
|
||||||
|
setActiveNoteId(null)
|
||||||
noteMapDirtyRef.current = false
|
noteMapDirtyRef.current = false
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
@@ -2025,16 +2014,39 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateNote(anchor: NoteAnchorId, html: string) {
|
function updateNoteEntry(id: string, html: string) {
|
||||||
noteMapDirtyRef.current = true
|
noteMapDirtyRef.current = true
|
||||||
setNoteMap(prev => ({ ...prev, [anchor]: html }))
|
setNoteEntries(prev => prev.map(e => e.id === id ? { ...e, html, updatedAt: new Date().toISOString() } : e))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNoteEntry(anchor: NoteAnchorId, html = '') {
|
||||||
|
const entry: NoteEntry = { id: newNoteId(), anchor, html, updatedAt: html ? new Date().toISOString() : null }
|
||||||
|
if (html) noteMapDirtyRef.current = true
|
||||||
|
setNoteEntries(prev => [...prev, entry])
|
||||||
|
setActiveNoteId(entry.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNoteEntry(id: string) {
|
||||||
|
noteMapDirtyRef.current = true
|
||||||
|
setNoteEntries(prev => prev.filter(e => e.id !== id))
|
||||||
|
setActiveNoteId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAnchorNote(anchor: NoteAnchorId) {
|
||||||
|
if (openAnchor === anchor) {
|
||||||
|
setActiveNoteId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const existing = [...noteEntries].reverse().find(e => e.anchor === anchor)
|
||||||
|
if (existing) setActiveNoteId(existing.id)
|
||||||
|
else addNoteEntry(anchor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openAnchor) return
|
if (!openAnchor) return
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') setOpenAnchor(null)
|
if (e.key === 'Escape') setActiveNoteId(null)
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKeyDown)
|
document.addEventListener('keydown', onKeyDown)
|
||||||
return () => document.removeEventListener('keydown', onKeyDown)
|
return () => document.removeEventListener('keydown', onKeyDown)
|
||||||
@@ -2050,7 +2062,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
await readJson<{ ok: boolean }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
|
await readJson<{ ok: boolean }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ note: JSON.stringify(noteMap) }),
|
body: JSON.stringify({ note: serializeNoteEntries(noteEntries) }),
|
||||||
})
|
})
|
||||||
setNoteMessage('')
|
setNoteMessage('')
|
||||||
setAutoSaveMsg('Auto-saved.')
|
setAutoSaveMsg('Auto-saved.')
|
||||||
@@ -2061,7 +2073,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
}
|
}
|
||||||
}, 2000)
|
}, 2000)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [noteMap, noteId, auth.authenticated, noteLoading])
|
}, [noteEntries, noteId, auth.authenticated, noteLoading])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth.authenticated || !isEnrolled || !study || !section?.id) {
|
if (!auth.authenticated || !isEnrolled || !study || !section?.id) {
|
||||||
@@ -2112,12 +2124,14 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
|
|
||||||
function appendSelectedTextToNotes(selectedText: string, anchor: NoteAnchorId = 'general') {
|
function appendSelectedTextToNotes(selectedText: string, anchor: NoteAnchorId = 'general') {
|
||||||
if (!selectedText.trim()) return
|
if (!selectedText.trim()) return
|
||||||
const existing = noteMap[anchor] ?? ''
|
const safe = selectedText.trim().replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
const appended = existing
|
const latest = [...noteEntries].reverse().find(e => e.anchor === anchor)
|
||||||
? `${existing}<p>${selectedText.trim()}</p>`
|
if (latest) {
|
||||||
: `<p>${selectedText.trim()}</p>`
|
updateNoteEntry(latest.id, `${latest.html}<p>${safe}</p>`)
|
||||||
updateNote(anchor, appended)
|
setActiveNoteId(latest.id)
|
||||||
setOpenAnchor(anchor)
|
} else {
|
||||||
|
addNoteEntry(anchor, `<p>${safe}</p>`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLessonContentContextMenu(e: React.MouseEvent<HTMLDivElement>) {
|
function handleLessonContentContextMenu(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
@@ -2354,10 +2368,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
{isEnrolled && (
|
{isEnrolled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`note-anchor-btn${noteMap['scripture']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'scripture' ? ' note-anchor-btn--open' : ''}`}
|
className={`note-anchor-btn${hasNoteOn('scripture') ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'scripture' ? ' note-anchor-btn--open' : ''}`}
|
||||||
onClick={() => setOpenAnchor(curr => curr === 'scripture' ? null : 'scripture')}
|
onClick={() => toggleAnchorNote('scripture')}
|
||||||
aria-pressed={openAnchor === 'scripture'}
|
aria-pressed={openAnchor === 'scripture'}
|
||||||
>{noteMap['scripture']?.trim() ? '✎ Note' : '+ Note'}</button>
|
>{hasNoteOn('scripture') ? '✎ Note' : '+ Note'}</button>
|
||||||
)}
|
)}
|
||||||
<h2>Scripture Text</h2>
|
<h2>Scripture Text</h2>
|
||||||
{bsbPassageLoading
|
{bsbPassageLoading
|
||||||
@@ -2377,10 +2391,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
{isEnrolled && (
|
{isEnrolled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`note-anchor-btn${noteMap['commentary']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'commentary' ? ' note-anchor-btn--open' : ''}`}
|
className={`note-anchor-btn${hasNoteOn('commentary') ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'commentary' ? ' note-anchor-btn--open' : ''}`}
|
||||||
onClick={() => setOpenAnchor(curr => curr === 'commentary' ? null : 'commentary')}
|
onClick={() => toggleAnchorNote('commentary')}
|
||||||
aria-pressed={openAnchor === 'commentary'}
|
aria-pressed={openAnchor === 'commentary'}
|
||||||
>{noteMap['commentary']?.trim() ? '✎ Note' : '+ Note'}</button>
|
>{hasNoteOn('commentary') ? '✎ Note' : '+ Note'}</button>
|
||||||
)}
|
)}
|
||||||
<h2>Instructor Commentary</h2>
|
<h2>Instructor Commentary</h2>
|
||||||
{section.commentary.split(/\n{2,}/).map((para, i) => (
|
{section.commentary.split(/\n{2,}/).map((para, i) => (
|
||||||
@@ -2396,10 +2410,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
{isEnrolled && (
|
{isEnrolled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`note-anchor-btn${noteMap['questions']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'questions' ? ' note-anchor-btn--open' : ''}`}
|
className={`note-anchor-btn${hasNoteOn('questions') ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'questions' ? ' note-anchor-btn--open' : ''}`}
|
||||||
onClick={() => setOpenAnchor(curr => curr === 'questions' ? null : 'questions')}
|
onClick={() => toggleAnchorNote('questions')}
|
||||||
aria-pressed={openAnchor === 'questions'}
|
aria-pressed={openAnchor === 'questions'}
|
||||||
>{noteMap['questions']?.trim() ? '✎ Note' : '+ Note'}</button>
|
>{hasNoteOn('questions') ? '✎ Note' : '+ Note'}</button>
|
||||||
)}
|
)}
|
||||||
<h2>Reflection Questions</h2>
|
<h2>Reflection Questions</h2>
|
||||||
<ol className="study-detail-list study-detail-list--questions">
|
<ol className="study-detail-list study-detail-list--questions">
|
||||||
@@ -2621,20 +2635,52 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
<>
|
<>
|
||||||
{noteLoading ? (
|
{noteLoading ? (
|
||||||
<p style={{ color: '#7a7060', fontSize: '0.85rem' }}>Loading notes…</p>
|
<p style={{ color: '#7a7060', fontSize: '0.85rem' }}>Loading notes…</p>
|
||||||
) : openAnchor ? (
|
|
||||||
<NoteCard
|
|
||||||
anchorLabel={ANCHOR_LABELS[openAnchor] ?? 'Notes'}
|
|
||||||
html={noteMap[openAnchor] ?? ''}
|
|
||||||
autoSaveMsg={autoSaveMsg}
|
|
||||||
onChange={html => updateNote(openAnchor as NoteAnchorId, html)}
|
|
||||||
onClose={() => setOpenAnchor(null)}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<p style={{ fontSize: '0.85rem', color: '#5a5040', lineHeight: 1.55 }}>
|
<>
|
||||||
{savedNoteCount > 0
|
{savedEntries.length > 0 && (
|
||||||
? `${savedNoteCount} note${savedNoteCount !== 1 ? 's' : ''} saved. Click a section's Note button to edit.`
|
<ul className="note-entry-list" aria-label="Notes on this lesson">
|
||||||
: 'Click the Note button on Scripture, Commentary, or Reflection Questions to write notes here.'}
|
{savedEntries.map(entry => (
|
||||||
</p>
|
<li key={entry.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`note-entry${entry.id === activeNoteId ? ' note-entry--active' : ''}`}
|
||||||
|
onClick={() => setActiveNoteId(entry.id === activeNoteId ? null : entry.id)}
|
||||||
|
>
|
||||||
|
<span className="note-entry-label">{ANCHOR_LABELS[entry.anchor]}{entry.updatedAt ? ` · ${formatRelativeTime(entry.updatedAt)}` : ''}</span>
|
||||||
|
<span className="note-entry-snippet">{noteHtmlToText(entry.html).slice(0, 70)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{activeEntry && (
|
||||||
|
<NoteCard
|
||||||
|
key={activeEntry.id}
|
||||||
|
anchorLabel={ANCHOR_LABELS[activeEntry.anchor] ?? 'Notes'}
|
||||||
|
html={activeEntry.html}
|
||||||
|
updatedAt={activeEntry.updatedAt}
|
||||||
|
autoSaveMsg={autoSaveMsg}
|
||||||
|
onChange={html => updateNoteEntry(activeEntry.id, html)}
|
||||||
|
onDelete={() => deleteNoteEntry(activeEntry.id)}
|
||||||
|
onClose={() => setActiveNoteId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isEnrolled && (
|
||||||
|
<div className="note-add-row">
|
||||||
|
<span>Add a note on:</span>
|
||||||
|
{NOTE_ANCHORS.map(anchor => (
|
||||||
|
<button key={anchor} type="button" className="note-add-chip" onClick={() => addNoteEntry(anchor)}>
|
||||||
|
+ {ANCHOR_LABELS[anchor]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!activeEntry && savedEntries.length === 0 && (
|
||||||
|
<p style={{ fontSize: '0.85rem', color: '#5a5040', lineHeight: 1.55 }}>
|
||||||
|
Click the Note button on Scripture, Commentary, or Reflection Questions, or use the buttons above, to write notes here.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{autoSaveMsg && <p style={{ fontSize: '0.82rem', color: '#7a9c6a', marginTop: '0.35rem' }}>{autoSaveMsg}</p>}
|
{autoSaveMsg && <p style={{ fontSize: '0.82rem', color: '#7a9c6a', marginTop: '0.35rem' }}>{autoSaveMsg}</p>}
|
||||||
{noteMessage && <p className="study-note-status" style={{ marginTop: '0.35rem' }}>{noteMessage}</p>}
|
{noteMessage && <p className="study-note-status" style={{ marginTop: '0.35rem' }}>{noteMessage}</p>}
|
||||||
@@ -2730,6 +2776,7 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
|||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [activeNoteTab, setActiveNoteTab] = useState('')
|
const [activeNoteTab, setActiveNoteTab] = useState('')
|
||||||
const [noteQuery, setNoteQuery] = useState('')
|
const [noteQuery, setNoteQuery] = useState('')
|
||||||
|
const [noteSort, setNoteSort] = useState<'lesson' | 'recent'>('lesson')
|
||||||
|
|
||||||
usePageMeta(
|
usePageMeta(
|
||||||
study ? `My Notes | ${study.title}` : 'My Study Notes',
|
study ? `My Notes | ${study.title}` : 'My Study Notes',
|
||||||
@@ -2811,8 +2858,10 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
const stripHtml = (html: string) => DOMPurify.sanitize(html, { ALLOWED_TAGS: [] }).replace(/\s+/g, ' ').trim()
|
const stripHtml = (html: string) => DOMPurify.sanitize(html, { ALLOWED_TAGS: [] }).replace(/\s+/g, ' ').trim()
|
||||||
const noteText = (raw: string) => Object.values(parseNoteMap(raw)).map(value => stripHtml(value ?? '')).filter(Boolean).join(' · ')
|
const noteText = (raw: string) => parseNoteEntries(raw).map(e => stripHtml(e.html)).filter(Boolean).join(' · ')
|
||||||
|
const lastEdited = (raw: string) => parseNoteEntries(raw).reduce((max, e) => (e.updatedAt && e.updatedAt > max ? e.updatedAt : max), '')
|
||||||
const sorted = [...entries].sort((a, b) => {
|
const sorted = [...entries].sort((a, b) => {
|
||||||
|
if (noteSort === 'recent') return lastEdited(b.note).localeCompare(lastEdited(a.note))
|
||||||
const aNum = a.section ? getLessonNumber(study.sections, a.section.id) : 0
|
const aNum = a.section ? getLessonNumber(study.sections, a.section.id) : 0
|
||||||
const bNum = b.section ? getLessonNumber(study.sections, b.section.id) : 0
|
const bNum = b.section ? getLessonNumber(study.sections, b.section.id) : 0
|
||||||
return aNum - bNum
|
return aNum - bNum
|
||||||
@@ -2837,9 +2886,8 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
|||||||
|
|
||||||
function downloadStudyNotesMarkdown() {
|
function downloadStudyNotesMarkdown() {
|
||||||
const body = sorted.map(({ section, sectionId, note }) => {
|
const body = sorted.map(({ section, sectionId, note }) => {
|
||||||
const blocks = Object.entries(parseNoteMap(note))
|
const blocks = parseNoteEntries(note)
|
||||||
.filter(([, html]) => html?.trim())
|
.map(e => `### ${ANCHOR_LABELS[e.anchor]}${e.updatedAt ? ` (${new Date(e.updatedAt).toLocaleDateString('en-US')})` : ''}\n\n${stripHtml(e.html)}`)
|
||||||
.map(([key, html]) => `### ${ANCHOR_LABELS[key] ?? key}\n\n${stripHtml(html)}`)
|
|
||||||
.join('\n\n')
|
.join('\n\n')
|
||||||
return `## ${section?.title ?? sectionId}${section?.reference ? ` (${section.reference})` : ''}\n\n${blocks}`
|
return `## ${section?.title ?? sectionId}${section?.reference ? ` (${section.reference})` : ''}\n\n${blocks}`
|
||||||
}).join('\n\n---\n\n')
|
}).join('\n\n---\n\n')
|
||||||
@@ -2925,6 +2973,10 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
|||||||
value={noteQuery}
|
value={noteQuery}
|
||||||
onChange={e => setNoteQuery(e.target.value)}
|
onChange={e => setNoteQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<div className="notes-sort" role="group" aria-label="Sort notes">
|
||||||
|
<button type="button" className={noteSort === 'lesson' ? 'notes-sort-btn notes-sort-btn--active' : 'notes-sort-btn'} onClick={() => setNoteSort('lesson')}>Lesson order</button>
|
||||||
|
<button type="button" className={noteSort === 'recent' ? 'notes-sort-btn notes-sort-btn--active' : 'notes-sort-btn'} onClick={() => setNoteSort('recent')}>Recently edited</button>
|
||||||
|
</div>
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
<p className="notes-empty-text">No notes match “{noteQuery.trim()}” in this study.</p>
|
<p className="notes-empty-text">No notes match “{noteQuery.trim()}” in this study.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -2944,7 +2996,7 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
|||||||
<span className="notes-item-number" aria-hidden="true">{lessonNumber || '•'}</span>
|
<span className="notes-item-number" aria-hidden="true">{lessonNumber || '•'}</span>
|
||||||
<span className="notes-item-body">
|
<span className="notes-item-body">
|
||||||
<strong>{section?.title ?? 'Saved note'}</strong>
|
<strong>{section?.title ?? 'Saved note'}</strong>
|
||||||
<small>{section?.reference ?? ''}</small>
|
<small>{section?.reference ?? ''}{lastEdited(note) ? ` · edited ${formatRelativeTime(lastEdited(note))}` : ''}</small>
|
||||||
<span className="notes-item-snippet">{noteText(note).slice(0, 110)}</span>
|
<span className="notes-item-snippet">{noteText(note).slice(0, 110)}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -2985,22 +3037,23 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="notes-copy-btn"
|
className="notes-copy-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const text = Object.entries(parseNoteMap(active.note))
|
const text = parseNoteEntries(active.note)
|
||||||
.filter(([, html]) => html?.trim())
|
.map(e => `${ANCHOR_LABELS[e.anchor]}\n${stripHtml(e.html)}`)
|
||||||
.map(([key, html]) => `${ANCHOR_LABELS[key] ?? key}\n${stripHtml(html)}`)
|
|
||||||
.join('\n\n')
|
.join('\n\n')
|
||||||
navigator.clipboard?.writeText(text).then(() => setMessage('Note copied to clipboard.'), () => setMessage('Unable to copy this note.'))
|
navigator.clipboard?.writeText(text).then(() => setMessage('Note copied to clipboard.'), () => setMessage('Unable to copy this note.'))
|
||||||
}}
|
}}
|
||||||
>Copy note</button>
|
>Copy note</button>
|
||||||
</p>
|
</p>
|
||||||
{(() => {
|
{(() => {
|
||||||
const anchorMap = parseNoteMap(active.note)
|
const blocks = parseNoteEntries(active.note)
|
||||||
const blocks = Object.entries(anchorMap).filter(([, html]) => html?.trim())
|
|
||||||
if (blocks.length === 0) return <p className="notes-empty-text">No notes saved for this lesson yet.</p>
|
if (blocks.length === 0) return <p className="notes-empty-text">No notes saved for this lesson yet.</p>
|
||||||
return blocks.map(([key, html]) => (
|
return blocks.map(entry => (
|
||||||
<section className="notes-block" key={key}>
|
<section className="notes-block" key={entry.id}>
|
||||||
<h3>{ANCHOR_LABELS[key] ?? key}</h3>
|
<h3>
|
||||||
<div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
|
{ANCHOR_LABELS[entry.anchor]}
|
||||||
|
{entry.updatedAt && <small className="notes-block-date"> · edited {new Date(entry.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</small>}
|
||||||
|
</h3>
|
||||||
|
<div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(entry.html) }} />
|
||||||
</section>
|
</section>
|
||||||
))
|
))
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { parseNoteEntries, serializeNoteEntries, noteEntriesToText } from '../shared/note-format.js'
|
||||||
|
|
||||||
|
test('legacy anchor map upgrades to entries and round-trips as v2', () => {
|
||||||
|
const entries = parseNoteEntries(JSON.stringify({ scripture: '<p>a</p>', general: '', commentary: '<p>b</p>' }))
|
||||||
|
assert.deepEqual(entries.map(e => e.anchor), ['scripture', 'commentary'])
|
||||||
|
const again = parseNoteEntries(serializeNoteEntries(entries))
|
||||||
|
assert.equal(again.length, 2)
|
||||||
|
assert.equal(JSON.parse(serializeNoteEntries(entries)).v, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('plain-text legacy notes become a general entry', () => {
|
||||||
|
const entries = parseNoteEntries('just some text')
|
||||||
|
assert.equal(entries.length, 1)
|
||||||
|
assert.equal(entries[0].anchor, 'general')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multiple entries per anchor are kept and empty ones dropped', () => {
|
||||||
|
const raw = serializeNoteEntries([
|
||||||
|
{ id: '1', anchor: 'general', html: '<p>one</p>', updatedAt: '2026-01-01T00:00:00.000Z' },
|
||||||
|
{ id: '2', anchor: 'general', html: '<p>two</p>', updatedAt: null },
|
||||||
|
{ id: '3', anchor: 'general', html: ' ', updatedAt: null },
|
||||||
|
])
|
||||||
|
assert.equal(parseNoteEntries(raw).length, 2)
|
||||||
|
assert.equal(serializeNoteEntries([]), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('plain-text export strips markup', () => {
|
||||||
|
const text = noteEntriesToText(serializeNoteEntries([{ id: '1', anchor: 'scripture', html: '<p>Hi & bye</p>', updatedAt: null }]))
|
||||||
|
assert.equal(text, 'Scripture Text\nHi & bye')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user