Add study sessions and timeline
This commit is contained in:
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
Generated
+1
-1
@@ -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,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,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
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user