Add study sessions and timeline
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "libre-study",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "libre-study",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"react": "^19.2.7",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "libre-study",
|
||||
"private": true,
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
|
||||
Generated
+1
-1
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "app"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rusqlite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Libre Study",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"identifier": "org.librestudy.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+119
-19
@@ -342,34 +342,38 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.original-language-verse p {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lemma-token {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
padding: 5px 7px;
|
||||
border-color: #d4c9b3;
|
||||
background: #fbf3df;
|
||||
.lemma-line-text {
|
||||
color: #1f211e;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.lemma-token small {
|
||||
color: #735c0f;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 9px;
|
||||
.lemma-line-text[dir='rtl'] {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.lemma-token.active {
|
||||
border-color: #2f6f5e;
|
||||
background: #e7f0eb;
|
||||
.lemma-word {
|
||||
display: inline;
|
||||
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 {
|
||||
@@ -444,6 +448,23 @@ textarea:focus-visible {
|
||||
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,
|
||||
.reader-tags {
|
||||
display: flex;
|
||||
@@ -496,6 +517,7 @@ textarea:focus-visible {
|
||||
color: #3f443d;
|
||||
font-size: 14px;
|
||||
line-height: 1.42;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.footnote-list p span {
|
||||
@@ -505,6 +527,84 @@ textarea:focus-visible {
|
||||
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 {
|
||||
border-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 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