Add study sessions and timeline

This commit is contained in:
2026-07-12 09:29:44 -05:00
parent d710035045
commit 2731c00223
7 changed files with 465 additions and 44 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "libre-study", "name": "libre-study",
"version": "0.1.7", "version": "0.1.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "libre-study", "name": "libre-study",
"version": "0.1.7", "version": "0.1.8",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"react": "^19.2.7", "react": "^19.2.7",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "libre-study", "name": "libre-study",
"private": true, "private": true,
"version": "0.1.7", "version": "0.1.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 127.0.0.1", "dev": "vite --host 127.0.0.1",
+1 -1
View File
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]] [[package]]
name = "app" name = "app"
version = "0.1.7" version = "0.1.8"
dependencies = [ dependencies = [
"log", "log",
"rusqlite", "rusqlite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "app" name = "app"
version = "0.1.7" version = "0.1.8"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
license = "" license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Libre Study", "productName": "Libre Study",
"version": "0.1.7", "version": "0.1.8",
"identifier": "org.librestudy.desktop", "identifier": "org.librestudy.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+119 -19
View File
@@ -342,34 +342,38 @@ textarea:focus-visible {
} }
.original-language-verse p { .original-language-verse p {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0; margin: 0;
} }
.lemma-token { .lemma-line-text {
display: inline-flex;
align-items: baseline;
gap: 5px;
padding: 5px 7px;
border-color: #d4c9b3;
background: #fbf3df;
color: #1f211e; color: #1f211e;
font-family: Georgia, 'Times New Roman', serif; font-family: Georgia, 'Times New Roman', serif;
font-size: 15px; font-size: 20px;
font-weight: 700; line-height: 1.7;
} }
.lemma-token small { .lemma-line-text[dir='rtl'] {
color: #735c0f; direction: rtl;
font-family: ui-sans-serif, system-ui, sans-serif; text-align: right;
font-size: 9px; unicode-bidi: isolate;
} }
.lemma-token.active { .lemma-word {
border-color: #2f6f5e; display: inline;
background: #e7f0eb; padding: 0 2px;
border-radius: 3px;
cursor: pointer;
}
.lemma-word + .lemma-word {
margin-inline-start: 0.35em;
}
.lemma-word:hover,
.lemma-word:focus-visible,
.lemma-word.active {
background: rgba(47, 111, 94, 0.16);
outline: none;
} }
.linked-word-list { .linked-word-list {
@@ -444,6 +448,23 @@ textarea:focus-visible {
gap: 14px; gap: 14px;
} }
.session-manager {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) auto;
gap: 8px;
align-items: end;
}
.session-manager label {
display: grid;
gap: 4px;
color: #657061;
font-size: 11px;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.tag-editor, .tag-editor,
.reader-tags { .reader-tags {
display: flex; display: flex;
@@ -496,6 +517,7 @@ textarea:focus-visible {
color: #3f443d; color: #3f443d;
font-size: 14px; font-size: 14px;
line-height: 1.42; line-height: 1.42;
cursor: pointer;
} }
.footnote-list p span { .footnote-list p span {
@@ -505,6 +527,84 @@ textarea:focus-visible {
font-weight: 800; font-weight: 800;
} }
.footnote-list p:hover,
.footnote-list p:focus-visible {
color: #202124;
outline: none;
text-decoration: underline;
text-decoration-color: #d6a84f;
text-underline-offset: 3px;
}
.session-timeline {
display: grid;
gap: 10px;
padding-top: 2px;
}
.session-timeline header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.session-timeline header strong {
color: #657061;
font-size: 12px;
}
.session-timeline ol {
display: grid;
gap: 6px;
max-height: 240px;
margin: 0;
padding: 0;
overflow: auto;
list-style: none;
}
.session-timeline li {
margin: 0;
}
.session-timeline li button {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
gap: 4px 8px;
width: 100%;
padding: 8px 9px;
border-color: #ded6c7;
background: #fbf8ef;
color: #202124;
text-align: left;
}
.session-timeline li button:disabled {
cursor: default;
opacity: 1;
}
.session-timeline li button span,
.session-timeline li button small {
color: #657061;
font-size: 11px;
}
.session-timeline li button small {
grid-column: 2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-timeline p {
margin: 0;
color: #657061;
font-size: 13px;
line-height: 1.45;
}
.status-panel.error { .status-panel.error {
border-color: #b42318; border-color: #b42318;
color: #b42318; color: #b42318;
+340 -19
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { addTagToVerse, getLibraryState, searchVerses } from './studyClient' import { addTagToVerse, getLibraryState, searchVerses } from './studyClient'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { Verse } from './types' import type { Verse } from './types'
@@ -6,13 +6,68 @@ import './App.css'
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7'] const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
const pageSize = 34 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 ViewMode = 'page' | 'study' | 'notes' | 'compare'
type StrongDisplayMode = 'off' | 'highlight' | 'numbers' 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) { function reference(verse: Verse) {
return `${verse.book} ${verse.chapter}:${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() { function App() {
const [viewMode, setViewMode] = useState<ViewMode>('notes') const [viewMode, setViewMode] = useState<ViewMode>('notes')
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
@@ -24,8 +79,11 @@ function App() {
const [strongDisplay, setStrongDisplay] = useState<StrongDisplayMode>('highlight') const [strongDisplay, setStrongDisplay] = useState<StrongDisplayMode>('highlight')
const [tagInput, setTagInput] = useState('') const [tagInput, setTagInput] = useState('')
const [noteDraft, setNoteDraft] = useState('') const [noteDraft, setNoteDraft] = useState('')
const [sessions, setSessions] = useState<StudySession[]>([])
const [activeSessionId, setActiveSessionId] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const restoredSessionId = useRef('')
async function loadLibrary() { async function loadLibrary() {
setError(null) setError(null)
@@ -37,6 +95,43 @@ function App() {
setPageStartId((current) => current ?? firstId) 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) { async function runSearch(nextQuery = query) {
setError(null) setError(null)
const trimmed = nextQuery.trim() const trimmed = nextQuery.trim()
@@ -46,6 +141,17 @@ function App() {
const firstResult = results[0] const firstResult = results[0]
setSelectedId(firstResult?.id ?? null) setSelectedId(firstResult?.id ?? null)
setPageStartId(firstResult ? findPageStartId(firstResult) : 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() { async function addTag() {
@@ -65,11 +171,61 @@ function App() {
} }
useEffect(() => { 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() loadLibrary()
.catch((reason) => setError(String(reason))) .catch((reason) => setError(String(reason)))
.finally(() => setLoading(false)) .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( const selectedVerse = useMemo(
() => () =>
libraryVerses.find((verse) => verse.id === selectedId) ?? libraryVerses.find((verse) => verse.id === selectedId) ??
@@ -84,6 +240,10 @@ function App() {
selectedVerse.strongs[0] selectedVerse.strongs[0]
) )
}, [selectedStrong, selectedVerse]) }, [selectedStrong, selectedVerse])
const activeSession = useMemo(
() => sessions.find((session) => session.id === activeSessionId) ?? sessions[0],
[activeSessionId, sessions],
)
const pageAnchors = useMemo(() => { const pageAnchors = useMemo(() => {
const source = libraryVerses.length ? libraryVerses : verses const source = libraryVerses.length ? libraryVerses : verses
const anchors: Verse[] = [] const anchors: Verse[] = []
@@ -150,6 +310,12 @@ function App() {
function searchStrong(strong: string) { function searchStrong(strong: string) {
setQuery(strong) setQuery(strong)
setSelectedStrong(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))) runSearch(strong).catch((reason) => setError(String(reason)))
} }
@@ -167,7 +333,17 @@ function App() {
setSelectedStrong(null) setSelectedStrong(null)
setVerses(libraryVerses) setVerses(libraryVerses)
setSelectedId(next.id) 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) { function shiftPage(direction: -1 | 1) {
@@ -183,6 +359,14 @@ function App() {
setPageStartId(next.id) setPageStartId(next.id)
setSelectedId(next.id) setSelectedId(next.id)
setSelectedStrong(null) 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) { function findPageStartId(verse: Verse) {
@@ -205,11 +389,81 @@ function App() {
function selectVerse(verse: Verse) { function selectVerse(verse: Verse) {
setSelectedId(verse.id) setSelectedId(verse.id)
setSelectedStrong(null) setSelectedStrong(null)
recordTimeline(
{
type: 'verse',
label: reference(verse),
reference: reference(verse),
},
{ selectedId: verse.id },
)
} }
function selectStrong(verse: Verse, strong: string) { function selectStrong(verse: Verse, strong: string) {
setSelectedId(verse.id) setSelectedId(verse.id)
setSelectedStrong(strong) 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) { function renderVerseText(verse: Verse) {
@@ -273,22 +527,30 @@ function App() {
function renderOriginalLanguageLine() { function renderOriginalLanguageLine() {
if (!selectedVerse?.strongs.length) return null if (!selectedVerse?.strongs.length) return null
const isHebrew = selectedVerse.strongs.some((entry) => entry.strong.startsWith('H'))
const direction = isHebrew ? 'rtl' : 'ltr'
return ( return (
<section className="original-language-verse" aria-label="Original language lemma line"> <section className="original-language-verse" aria-label="Original language lemma line">
<h3>Lemma Line</h3> <h3>{isHebrew ? 'Hebrew Lemma Line' : 'Greek Lemma Line'}</h3>
<p> <p className="lemma-line-text" dir={direction}>
{selectedVerse.strongs.map((entry) => ( {selectedVerse.strongs.map((entry) => (
<button <span
key={`${entry.strong}-${entry.position}-lemma`} key={`${entry.strong}-${entry.position}-lemma`}
type="button" className={entry.strong === selectedStrongEntry?.strong ? 'lemma-word active' : 'lemma-word'}
className={entry.strong === selectedStrongEntry?.strong ? 'lemma-token active' : 'lemma-token'} role="button"
tabIndex={0}
title={`${entry.surface}: ${entry.strong}`} 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> {entry.lemma || entry.surface}
<small>{entry.strong}</small> </span>
</button>
))} ))}
</p> </p>
</section> </section>
@@ -346,7 +608,7 @@ function App() {
className={entry.strong === selectedStrongEntry?.strong ? 'word-link active' : 'word-link'} className={entry.strong === selectedStrongEntry?.strong ? 'word-link active' : 'word-link'}
type="button" type="button"
title={`${entry.surface}: ${entry.strong}`} title={`${entry.surface}: ${entry.strong}`}
onClick={() => setSelectedStrong(entry.strong)} onClick={() => selectStrong(selectedVerse, entry.strong)}
> >
<span>{entry.surface}</span> <span>{entry.surface}</span>
<small>{entry.strong}</small> <small>{entry.strong}</small>
@@ -379,7 +641,18 @@ function App() {
{visibleFootnotes.length ? ( {visibleFootnotes.length ? (
<div className="footnote-list"> <div className="footnote-list">
{visibleFootnotes.map((note) => ( {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> <span>{note.reference}</span>
{note.text} {note.text}
</p> </p>
@@ -396,10 +669,10 @@ function App() {
<main className="app-shell"> <main className="app-shell">
<aside className="tool-rail" aria-label="Main tools"> <aside className="tool-rail" aria-label="Main tools">
<span className="brand-mark">LS</span> <span className="brand-mark">LS</span>
<button type="button" title="Bible page" onClick={() => setViewMode('page')}>B</button> <button type="button" title="Bible page" onClick={() => setWorkspaceView('page')}>B</button>
<button type="button" title="Study margin" onClick={() => setViewMode('study')}>M</button> <button type="button" title="Study margin" onClick={() => setWorkspaceView('study')}>M</button>
<button type="button" title="Notes workspace" onClick={() => setViewMode('notes')}>N</button> <button type="button" title="Notes workspace" onClick={() => setWorkspaceView('notes')}>N</button>
<button type="button" title="Compare translations" onClick={() => setViewMode('compare')}>C</button> <button type="button" title="Compare translations" onClick={() => setWorkspaceView('compare')}>C</button>
</aside> </aside>
<section className="reader"> <section className="reader">
@@ -473,7 +746,7 @@ function App() {
<select <select
value={strongDisplay} value={strongDisplay}
onChange={(event) => onChange={(event) =>
setStrongDisplay(event.target.value as StrongDisplayMode) setStrongDisplayMode(event.target.value as StrongDisplayMode)
} }
> >
<option value="off">Off</option> <option value="off">Off</option>
@@ -533,9 +806,32 @@ function App() {
<span className="eyebrow">Notes</span> <span className="eyebrow">Notes</span>
<h2>Study Notes</h2> <h2>Study Notes</h2>
</header> </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 <textarea
value={noteDraft} 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." placeholder="Write sermon notes, study observations, or drag clippings here later."
/> />
<div className="tag-editor"> <div className="tag-editor">
@@ -560,6 +856,31 @@ function App() {
</span> </span>
))} ))}
</div> </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> </aside>
) : null} ) : null}
</div> </div>