Add study sessions and timeline
This commit is contained in:
+340
-19
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { addTagToVerse, getLibraryState, searchVerses } from './studyClient'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Verse } from './types'
|
||||
@@ -6,13 +6,68 @@ import './App.css'
|
||||
|
||||
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
|
||||
const pageSize = 34
|
||||
const sessionStorageKey = 'libre-study.sessions.v1'
|
||||
const activeSessionStorageKey = 'libre-study.active-session.v1'
|
||||
const timelineLimit = 200
|
||||
type ViewMode = 'page' | 'study' | 'notes' | 'compare'
|
||||
type StrongDisplayMode = 'off' | 'highlight' | 'numbers'
|
||||
type TimelineEntryType = 'verse' | 'strong' | 'footnote' | 'search' | 'navigation' | 'view' | 'setting'
|
||||
|
||||
type TimelineEntry = {
|
||||
id: string
|
||||
at: string
|
||||
type: TimelineEntryType
|
||||
label: string
|
||||
reference?: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
type StudySession = {
|
||||
id: string
|
||||
title: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
selectedId: number | null
|
||||
pageStartId: number | null
|
||||
viewMode: ViewMode
|
||||
strongDisplay: StrongDisplayMode
|
||||
noteDraft: string
|
||||
timeline: TimelineEntry[]
|
||||
}
|
||||
|
||||
function reference(verse: Verse) {
|
||||
return `${verse.book} ${verse.chapter}:${verse.verse}`
|
||||
}
|
||||
|
||||
function createSession(title = 'Study Session'): StudySession {
|
||||
const now = new Date().toISOString()
|
||||
const id =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
selectedId: null,
|
||||
pageStartId: null,
|
||||
viewMode: 'notes',
|
||||
strongDisplay: 'highlight',
|
||||
noteDraft: '',
|
||||
timeline: [],
|
||||
}
|
||||
}
|
||||
|
||||
function timelineId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
function formatTimelineTime(value: string) {
|
||||
return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('notes')
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -24,8 +79,11 @@ function App() {
|
||||
const [strongDisplay, setStrongDisplay] = useState<StrongDisplayMode>('highlight')
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
const [noteDraft, setNoteDraft] = useState('')
|
||||
const [sessions, setSessions] = useState<StudySession[]>([])
|
||||
const [activeSessionId, setActiveSessionId] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const restoredSessionId = useRef('')
|
||||
|
||||
async function loadLibrary() {
|
||||
setError(null)
|
||||
@@ -37,6 +95,43 @@ function App() {
|
||||
setPageStartId((current) => current ?? firstId)
|
||||
}
|
||||
|
||||
function patchActiveSession(patch: Partial<StudySession>) {
|
||||
if (!activeSessionId) return
|
||||
const updatedAt = new Date().toISOString()
|
||||
setSessions((current) =>
|
||||
current.map((session) =>
|
||||
session.id === activeSessionId ? { ...session, ...patch, updatedAt } : session,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function recordTimeline(
|
||||
entry: Omit<TimelineEntry, 'id' | 'at'>,
|
||||
patch: Partial<StudySession> = {},
|
||||
) {
|
||||
if (!activeSessionId) return
|
||||
const at = new Date().toISOString()
|
||||
const nextEntry: TimelineEntry = { id: timelineId(), at, ...entry }
|
||||
|
||||
setSessions((current) =>
|
||||
current.map((session) =>
|
||||
session.id === activeSessionId
|
||||
? {
|
||||
...session,
|
||||
selectedId,
|
||||
pageStartId,
|
||||
viewMode,
|
||||
strongDisplay,
|
||||
noteDraft,
|
||||
...patch,
|
||||
updatedAt: at,
|
||||
timeline: [nextEntry, ...session.timeline].slice(0, timelineLimit),
|
||||
}
|
||||
: session,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function runSearch(nextQuery = query) {
|
||||
setError(null)
|
||||
const trimmed = nextQuery.trim()
|
||||
@@ -46,6 +141,17 @@ function App() {
|
||||
const firstResult = results[0]
|
||||
setSelectedId(firstResult?.id ?? null)
|
||||
setPageStartId(firstResult ? findPageStartId(firstResult) : null)
|
||||
recordTimeline(
|
||||
{
|
||||
type: 'search',
|
||||
label: trimmed ? `Search: ${trimmed}` : 'Cleared search',
|
||||
detail: `${results.length} matches`,
|
||||
},
|
||||
{
|
||||
selectedId: firstResult?.id ?? null,
|
||||
pageStartId: firstResult ? findPageStartId(firstResult) : null,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function addTag() {
|
||||
@@ -65,11 +171,61 @@ function App() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const savedSessions = localStorage.getItem(sessionStorageKey)
|
||||
const savedActiveSessionId = localStorage.getItem(activeSessionStorageKey)
|
||||
let nextSessions: StudySession[] = []
|
||||
|
||||
if (savedSessions) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedSessions) as StudySession[]
|
||||
nextSessions = Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
nextSessions = []
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextSessions.length) {
|
||||
nextSessions = [createSession('KJV Study Session')]
|
||||
}
|
||||
|
||||
setSessions(nextSessions)
|
||||
setActiveSessionId(
|
||||
nextSessions.some((session) => session.id === savedActiveSessionId)
|
||||
? savedActiveSessionId ?? nextSessions[0].id
|
||||
: nextSessions[0].id,
|
||||
)
|
||||
|
||||
loadLibrary()
|
||||
.catch((reason) => setError(String(reason)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (sessions.length) {
|
||||
localStorage.setItem(sessionStorageKey, JSON.stringify(sessions))
|
||||
}
|
||||
}, [sessions])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSessionId) {
|
||||
localStorage.setItem(activeSessionStorageKey, activeSessionId)
|
||||
}
|
||||
}, [activeSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (restoredSessionId.current === activeSessionId) return
|
||||
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
||||
if (!activeSession) return
|
||||
|
||||
restoredSessionId.current = activeSessionId
|
||||
setViewMode(activeSession.viewMode)
|
||||
setStrongDisplay(activeSession.strongDisplay)
|
||||
setNoteDraft(activeSession.noteDraft)
|
||||
setSelectedId((current) => activeSession.selectedId ?? current)
|
||||
setPageStartId((current) => activeSession.pageStartId ?? current)
|
||||
setSelectedStrong(null)
|
||||
}, [activeSessionId, sessions])
|
||||
|
||||
const selectedVerse = useMemo(
|
||||
() =>
|
||||
libraryVerses.find((verse) => verse.id === selectedId) ??
|
||||
@@ -84,6 +240,10 @@ function App() {
|
||||
selectedVerse.strongs[0]
|
||||
)
|
||||
}, [selectedStrong, selectedVerse])
|
||||
const activeSession = useMemo(
|
||||
() => sessions.find((session) => session.id === activeSessionId) ?? sessions[0],
|
||||
[activeSessionId, sessions],
|
||||
)
|
||||
const pageAnchors = useMemo(() => {
|
||||
const source = libraryVerses.length ? libraryVerses : verses
|
||||
const anchors: Verse[] = []
|
||||
@@ -150,6 +310,12 @@ function App() {
|
||||
function searchStrong(strong: string) {
|
||||
setQuery(strong)
|
||||
setSelectedStrong(strong)
|
||||
recordTimeline({
|
||||
type: 'strong',
|
||||
label: `Strong's ${strong}`,
|
||||
reference: selectedVerse ? reference(selectedVerse) : undefined,
|
||||
detail: 'Opened matching verses search',
|
||||
})
|
||||
runSearch(strong).catch((reason) => setError(String(reason)))
|
||||
}
|
||||
|
||||
@@ -167,7 +333,17 @@ function App() {
|
||||
setSelectedStrong(null)
|
||||
setVerses(libraryVerses)
|
||||
setSelectedId(next.id)
|
||||
setPageStartId(findPageStartId(next))
|
||||
const nextPageStartId = findPageStartId(next)
|
||||
setPageStartId(nextPageStartId)
|
||||
recordTimeline(
|
||||
{
|
||||
type: 'navigation',
|
||||
label: reference(next),
|
||||
reference: reference(next),
|
||||
detail: 'Jumped from selector',
|
||||
},
|
||||
{ selectedId: next.id, pageStartId: nextPageStartId },
|
||||
)
|
||||
}
|
||||
|
||||
function shiftPage(direction: -1 | 1) {
|
||||
@@ -183,6 +359,14 @@ function App() {
|
||||
setPageStartId(next.id)
|
||||
setSelectedId(next.id)
|
||||
setSelectedStrong(null)
|
||||
recordTimeline(
|
||||
{
|
||||
type: 'navigation',
|
||||
label: direction > 0 ? 'Next page' : 'Previous page',
|
||||
reference: reference(next),
|
||||
},
|
||||
{ selectedId: next.id, pageStartId: next.id },
|
||||
)
|
||||
}
|
||||
|
||||
function findPageStartId(verse: Verse) {
|
||||
@@ -205,11 +389,81 @@ function App() {
|
||||
function selectVerse(verse: Verse) {
|
||||
setSelectedId(verse.id)
|
||||
setSelectedStrong(null)
|
||||
recordTimeline(
|
||||
{
|
||||
type: 'verse',
|
||||
label: reference(verse),
|
||||
reference: reference(verse),
|
||||
},
|
||||
{ selectedId: verse.id },
|
||||
)
|
||||
}
|
||||
|
||||
function selectStrong(verse: Verse, strong: string) {
|
||||
setSelectedId(verse.id)
|
||||
setSelectedStrong(strong)
|
||||
recordTimeline(
|
||||
{
|
||||
type: 'strong',
|
||||
label: strong,
|
||||
reference: reference(verse),
|
||||
},
|
||||
{ selectedId: verse.id },
|
||||
)
|
||||
}
|
||||
|
||||
function setWorkspaceView(nextViewMode: ViewMode) {
|
||||
setViewMode(nextViewMode)
|
||||
recordTimeline(
|
||||
{
|
||||
type: 'view',
|
||||
label: `View: ${nextViewMode}`,
|
||||
},
|
||||
{ viewMode: nextViewMode },
|
||||
)
|
||||
}
|
||||
|
||||
function setStrongDisplayMode(nextStrongDisplay: StrongDisplayMode) {
|
||||
setStrongDisplay(nextStrongDisplay)
|
||||
recordTimeline(
|
||||
{
|
||||
type: 'setting',
|
||||
label: `Strong's display: ${nextStrongDisplay}`,
|
||||
},
|
||||
{ strongDisplay: nextStrongDisplay },
|
||||
)
|
||||
}
|
||||
|
||||
function updateNoteDraft(value: string) {
|
||||
setNoteDraft(value)
|
||||
patchActiveSession({ noteDraft: value })
|
||||
}
|
||||
|
||||
function renameActiveSession(title: string) {
|
||||
patchActiveSession({ title })
|
||||
}
|
||||
|
||||
function startNewSession() {
|
||||
const nextSession = createSession(`Study Session ${sessions.length + 1}`)
|
||||
setSessions((current) => [nextSession, ...current])
|
||||
setActiveSessionId(nextSession.id)
|
||||
}
|
||||
|
||||
function restoreTimelineEntry(entry: TimelineEntry) {
|
||||
if (!entry.reference) return
|
||||
const [bookPart, rest] = entry.reference.split(/ (?=\d+:\d+$)/)
|
||||
const [chapter, verse] = rest?.split(':').map(Number) ?? []
|
||||
if (!bookPart || !chapter || !verse) return
|
||||
jumpTo(bookPart, chapter, verse)
|
||||
}
|
||||
|
||||
function recordFootnote(referenceText: string, text: string) {
|
||||
recordTimeline({
|
||||
type: 'footnote',
|
||||
label: `Footnote ${referenceText}`,
|
||||
reference: selectedVerse ? reference(selectedVerse) : undefined,
|
||||
detail: text,
|
||||
})
|
||||
}
|
||||
|
||||
function renderVerseText(verse: Verse) {
|
||||
@@ -273,22 +527,30 @@ function App() {
|
||||
|
||||
function renderOriginalLanguageLine() {
|
||||
if (!selectedVerse?.strongs.length) return null
|
||||
const isHebrew = selectedVerse.strongs.some((entry) => entry.strong.startsWith('H'))
|
||||
const direction = isHebrew ? 'rtl' : 'ltr'
|
||||
|
||||
return (
|
||||
<section className="original-language-verse" aria-label="Original language lemma line">
|
||||
<h3>Lemma Line</h3>
|
||||
<p>
|
||||
<h3>{isHebrew ? 'Hebrew Lemma Line' : 'Greek Lemma Line'}</h3>
|
||||
<p className="lemma-line-text" dir={direction}>
|
||||
{selectedVerse.strongs.map((entry) => (
|
||||
<button
|
||||
<span
|
||||
key={`${entry.strong}-${entry.position}-lemma`}
|
||||
type="button"
|
||||
className={entry.strong === selectedStrongEntry?.strong ? 'lemma-token active' : 'lemma-token'}
|
||||
className={entry.strong === selectedStrongEntry?.strong ? 'lemma-word active' : 'lemma-word'}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={`${entry.surface}: ${entry.strong}`}
|
||||
onClick={() => setSelectedStrong(entry.strong)}
|
||||
onClick={() => selectStrong(selectedVerse, entry.strong)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
selectStrong(selectedVerse, entry.strong)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{entry.lemma || entry.surface}</span>
|
||||
<small>{entry.strong}</small>
|
||||
</button>
|
||||
{entry.lemma || entry.surface}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</section>
|
||||
@@ -346,7 +608,7 @@ function App() {
|
||||
className={entry.strong === selectedStrongEntry?.strong ? 'word-link active' : 'word-link'}
|
||||
type="button"
|
||||
title={`${entry.surface}: ${entry.strong}`}
|
||||
onClick={() => setSelectedStrong(entry.strong)}
|
||||
onClick={() => selectStrong(selectedVerse, entry.strong)}
|
||||
>
|
||||
<span>{entry.surface}</span>
|
||||
<small>{entry.strong}</small>
|
||||
@@ -379,7 +641,18 @@ function App() {
|
||||
{visibleFootnotes.length ? (
|
||||
<div className="footnote-list">
|
||||
{visibleFootnotes.map((note) => (
|
||||
<p key={note.id}>
|
||||
<p
|
||||
key={note.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => recordFootnote(note.reference, note.text)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
recordFootnote(note.reference, note.text)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{note.reference}</span>
|
||||
{note.text}
|
||||
</p>
|
||||
@@ -396,10 +669,10 @@ function App() {
|
||||
<main className="app-shell">
|
||||
<aside className="tool-rail" aria-label="Main tools">
|
||||
<span className="brand-mark">LS</span>
|
||||
<button type="button" title="Bible page" onClick={() => setViewMode('page')}>B</button>
|
||||
<button type="button" title="Study margin" onClick={() => setViewMode('study')}>M</button>
|
||||
<button type="button" title="Notes workspace" onClick={() => setViewMode('notes')}>N</button>
|
||||
<button type="button" title="Compare translations" onClick={() => setViewMode('compare')}>C</button>
|
||||
<button type="button" title="Bible page" onClick={() => setWorkspaceView('page')}>B</button>
|
||||
<button type="button" title="Study margin" onClick={() => setWorkspaceView('study')}>M</button>
|
||||
<button type="button" title="Notes workspace" onClick={() => setWorkspaceView('notes')}>N</button>
|
||||
<button type="button" title="Compare translations" onClick={() => setWorkspaceView('compare')}>C</button>
|
||||
</aside>
|
||||
|
||||
<section className="reader">
|
||||
@@ -473,7 +746,7 @@ function App() {
|
||||
<select
|
||||
value={strongDisplay}
|
||||
onChange={(event) =>
|
||||
setStrongDisplay(event.target.value as StrongDisplayMode)
|
||||
setStrongDisplayMode(event.target.value as StrongDisplayMode)
|
||||
}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
@@ -533,9 +806,32 @@ function App() {
|
||||
<span className="eyebrow">Notes</span>
|
||||
<h2>Study Notes</h2>
|
||||
</header>
|
||||
<div className="session-manager" aria-label="Study session manager">
|
||||
<label>
|
||||
Session
|
||||
<select
|
||||
value={activeSessionId}
|
||||
onChange={(event) => setActiveSessionId(event.target.value)}
|
||||
>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.id} value={session.id}>
|
||||
{session.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<input
|
||||
value={activeSession?.title ?? ''}
|
||||
onChange={(event) => renameActiveSession(event.target.value)}
|
||||
placeholder="Session title"
|
||||
/>
|
||||
<button type="button" onClick={startNewSession}>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={noteDraft}
|
||||
onChange={(event) => setNoteDraft(event.target.value)}
|
||||
onChange={(event) => updateNoteDraft(event.target.value)}
|
||||
placeholder="Write sermon notes, study observations, or drag clippings here later."
|
||||
/>
|
||||
<div className="tag-editor">
|
||||
@@ -560,6 +856,31 @@ function App() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<section className="session-timeline" aria-label="Session timeline">
|
||||
<header>
|
||||
<span className="eyebrow">Timeline</span>
|
||||
<strong>{activeSession?.timeline.length ?? 0} events</strong>
|
||||
</header>
|
||||
{activeSession?.timeline.length ? (
|
||||
<ol>
|
||||
{activeSession.timeline.slice(0, 14).map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!entry.reference}
|
||||
onClick={() => restoreTimelineEntry(entry)}
|
||||
>
|
||||
<span>{formatTimelineTime(entry.at)}</span>
|
||||
<strong>{entry.label}</strong>
|
||||
{entry.detail ? <small>{entry.detail}</small> : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p>Click verses, Strong's entries, page controls, or footnotes to build this session timeline.</p>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user