diff --git a/package-lock.json b/package-lock.json index 3c62ed3..dbbeeee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9a98145..8925fb1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 018ae2f..af277ea 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "app" -version = "0.1.7" +version = "0.1.8" dependencies = [ "log", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1acb55b..5ebbf46 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "0.1.7" +version = "0.1.8" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 339955b..bac8030 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/App.css b/src/App.css index d1dc3ba..e987b4c 100644 --- a/src/App.css +++ b/src/App.css @@ -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; diff --git a/src/App.tsx b/src/App.tsx index 748f0e1..0a200a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('notes') const [query, setQuery] = useState('') @@ -24,8 +79,11 @@ function App() { const [strongDisplay, setStrongDisplay] = useState('highlight') const [tagInput, setTagInput] = useState('') const [noteDraft, setNoteDraft] = useState('') + const [sessions, setSessions] = useState([]) + const [activeSessionId, setActiveSessionId] = useState('') const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const restoredSessionId = useRef('') async function loadLibrary() { setError(null) @@ -37,6 +95,43 @@ function App() { setPageStartId((current) => current ?? firstId) } + function patchActiveSession(patch: Partial) { + if (!activeSessionId) return + const updatedAt = new Date().toISOString() + setSessions((current) => + current.map((session) => + session.id === activeSessionId ? { ...session, ...patch, updatedAt } : session, + ), + ) + } + + function recordTimeline( + entry: Omit, + patch: Partial = {}, + ) { + 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 (
-

Lemma Line

-

+

{isHebrew ? 'Hebrew Lemma Line' : 'Greek Lemma Line'}

+

{selectedVerse.strongs.map((entry) => ( - + {entry.lemma || entry.surface} + ))}

@@ -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)} > {entry.surface} {entry.strong} @@ -379,7 +641,18 @@ function App() { {visibleFootnotes.length ? (
{visibleFootnotes.map((note) => ( -

+

recordFootnote(note.reference, note.text)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + recordFootnote(note.reference, note.text) + } + }} + > {note.reference} {note.text}

@@ -396,10 +669,10 @@ function App() {
@@ -473,7 +746,7 @@ function App() { setActiveSessionId(event.target.value)} + > + {sessions.map((session) => ( + + ))} + + + renameActiveSession(event.target.value)} + placeholder="Session title" + /> + +