Extract shared workspace core

This commit is contained in:
2026-07-12 12:54:16 -05:00
parent 2fce849666
commit 8aafd3061b
9 changed files with 281 additions and 214 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.1.25 - 2026-07-12
- Started Phase 4 shared app core cleanup by extracting workspace/session/settings/timeline schema and persistence adapters into `src/workspace.ts`.
- Kept local preview and hosted workspace persistence behind the same load/save interface.
- Added the roadmap phase index so later resource-depth work such as maps, audio Bible, pronunciation audio, commentaries, dictionaries, lexicons, cross-references, and study notes remains visible.
## 0.1.24 - 2026-07-12
- Fixed the hosted deploy script to use a separate `dist-hosted` output, user-writable remote runtime paths, reliable WSL path conversion, explicit rsync/PM2 failure checks, and rsynced deploy metadata.
+14
View File
@@ -41,6 +41,20 @@ Keep one application, not separate apps. Split the code by adapters:
The hosted web version is the lead product lane and may add login and collaboration. The desktop version must remain usable without accounts when that lane resumes.
## Phase Index
- Phase 1: Private hosted preview.
- Phase 2: Server persistence and login.
- Phase 3: Deploy from Gitea.
- Phase 4: Shared app core.
- Phase 5: Storage and search foundation.
- Phase 6: Notes, exports, and Nextcloud.
- Phase 7: Study resource depth: maps, audio Bible, pronunciation audio, commentaries, dictionaries, lexicons, cross-references, and study notes.
- Phase 8: Collaboration.
- Phase 9: Desktop hardening.
- Phase 10: Public web release.
- Phase 11: Mobile and future platforms.
## Phase 1: Private Hosted Preview
Goal: get the current working app online privately on Christ Unscripted as fast as possible and establish the hosted app as the main development target.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "libre-study",
"version": "0.1.24",
"version": "0.1.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "libre-study",
"version": "0.1.24",
"version": "0.1.25",
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"react": "^19.2.7",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "libre-study",
"private": true,
"version": "0.1.24",
"version": "0.1.25",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
+1 -1
View File
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "app"
version = "0.1.24"
version = "0.1.25"
dependencies = [
"log",
"rusqlite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "app"
version = "0.1.24"
version = "0.1.25"
description = "A Tauri App"
authors = ["you"]
license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Libre Study",
"version": "0.1.24",
"version": "0.1.25",
"identifier": "org.librestudy.desktop",
"build": {
"frontendDist": "../dist",
+30 -208
View File
@@ -8,77 +8,30 @@ import {
getTranslations,
searchVerses,
} from './studyClient'
import {
createSession,
defaultTranslationId,
hostedWorkspaceEnabled,
loadWorkspaceSnapshot,
saveWorkspaceSnapshot,
timelineId,
timelineLimit,
} from './workspace'
import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
import type { CommentaryEntry, DictionaryEntry, TranslationResource, Verse } from './types'
import type {
StrongDisplayMode,
StudySession,
StudySettings,
ThemeMode,
TimelineEntry,
ViewMode,
} from './workspace'
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 studySettingsStorageKey = 'libre-study.study-settings.v1'
const timelineLimit = 200
const defaultTranslationId = 'kjv-eng-kjv2006'
const remoteWorkspaceEnabled = import.meta.env.VITE_REMOTE_WORKSPACE === 'true'
const remoteWorkspaceApiBase = (import.meta.env.VITE_API_BASE_URL ?? '/api').replace(/\/$/, '')
type ViewMode = 'page' | 'study' | 'notes' | 'compare'
type StrongDisplayMode = 'off' | 'highlight' | 'numbers'
type PageMotion = 'idle' | 'turn-forward' | 'turn-back'
type ThemeMode = 'light' | 'dark'
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
baseTranslation: string
footnotesEnabled: boolean
strongsEnabled: boolean
commentaryEnabled: boolean
dictionaryEnabled: boolean
timelineEnabled: boolean
createdAt: string
updatedAt: string
selectedId: number | null
pageStartId: number | null
viewMode: ViewMode
strongDisplay: StrongDisplayMode
noteDraft: string
timeline: TimelineEntry[]
}
type StudySettings = {
lemmaFontSize: number
linkedFontSize: number
originalCollapsed: boolean
linkedCollapsed: boolean
definitionCollapsed: boolean
studyNotesCollapsed: boolean
commentaryCollapsed: boolean
dictionaryCollapsed: boolean
showOriginalLine: boolean
showLinkedLine: boolean
showDefinitionPanel: boolean
showCommentaryPanel: boolean
showDictionaryPanel: boolean
splitPercent: number
notesPinned: boolean
themeMode: ThemeMode
}
type WorkspaceSnapshot = {
sessions: StudySession[]
activeSessionId: string
studySettings: StudySettings
}
function reference(verse: Verse) {
return `${verse.book} ${verse.chapter}:${verse.verse}`
@@ -94,75 +47,10 @@ function replaceChangedVerses(verses: Verse[], hydrate: (verse: Verse) => Verse)
return changed ? next : verses
}
function createSession(title = 'Study Session', baseTranslation = defaultTranslationId): 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,
baseTranslation,
footnotesEnabled: true,
strongsEnabled: true,
commentaryEnabled: true,
dictionaryEnabled: true,
timelineEnabled: true,
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' })
}
async function loadRemoteWorkspaceSnapshot(): Promise<Partial<WorkspaceSnapshot> | null> {
if (!remoteWorkspaceEnabled) return null
const response = await fetch(`${remoteWorkspaceApiBase}/workspace`, {
credentials: 'include',
})
if (response.status === 204 || response.status === 404) return null
if (response.status === 401) {
throw new Error('Login is required for the hosted workspace.')
}
if (!response.ok) {
throw new Error(`Unable to load hosted workspace: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<Partial<WorkspaceSnapshot>>
}
async function saveRemoteWorkspaceSnapshot(snapshot: WorkspaceSnapshot) {
if (!remoteWorkspaceEnabled) return
const response = await fetch(`${remoteWorkspaceApiBase}/workspace`, {
method: 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(snapshot),
})
if (!response.ok) {
throw new Error(`Unable to save hosted workspace: ${response.status} ${response.statusText}`)
}
}
function abbreviateBook(book: string) {
const abbreviations: Record<string, string> = {
Genesis: 'Gen',
@@ -535,66 +423,14 @@ function App() {
let cancelled = false
async function restoreWorkspace() {
let remoteSnapshot: Partial<WorkspaceSnapshot> | null = null
if (remoteWorkspaceEnabled) {
try {
remoteSnapshot = await loadRemoteWorkspaceSnapshot()
} catch (reason) {
setError(String(reason))
}
}
const savedStudySettings = remoteSnapshot?.studySettings
? JSON.stringify(remoteSnapshot.studySettings)
: localStorage.getItem(studySettingsStorageKey)
const savedSessions = remoteSnapshot?.sessions
? JSON.stringify(remoteSnapshot.sessions)
: localStorage.getItem(sessionStorageKey)
const savedActiveSessionId =
remoteSnapshot?.activeSessionId ?? localStorage.getItem(activeSessionStorageKey)
let nextSessions: StudySession[] = []
if (savedStudySettings) {
try {
applyStudySettings(JSON.parse(savedStudySettings) as Partial<StudySettings>)
} catch {
localStorage.removeItem(studySettingsStorageKey)
}
}
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')]
}
nextSessions = nextSessions.map((session) => ({
...createSession(session.title),
...session,
baseTranslation: session.baseTranslation ?? defaultTranslationId,
footnotesEnabled: session.footnotesEnabled ?? true,
strongsEnabled: session.strongsEnabled ?? true,
commentaryEnabled: session.commentaryEnabled ?? true,
dictionaryEnabled: session.dictionaryEnabled ?? true,
timelineEnabled: session.timelineEnabled ?? true,
}))
const selectedSessionId = nextSessions.some((session) => session.id === savedActiveSessionId)
? savedActiveSessionId ?? nextSessions[0].id
: nextSessions[0].id
const workspace = await loadWorkspaceSnapshot()
const selectedSession =
nextSessions.find((session) => session.id === selectedSessionId) ?? nextSessions[0]
workspace.sessions.find((session) => session.id === workspace.activeSessionId) ?? workspace.sessions[0]
if (cancelled) return
setSessions(nextSessions)
setActiveSessionId(selectedSessionId)
applyStudySettings(workspace.studySettings)
setSessions(workspace.sessions)
setActiveSessionId(workspace.activeSessionId)
getTranslations().then(setTranslations).catch((reason) => setError(String(reason)))
loadLibrary(selectedSession.baseTranslation)
@@ -622,26 +458,7 @@ function App() {
}, [])
useEffect(() => {
if (sessions.length) {
localStorage.setItem(sessionStorageKey, JSON.stringify(sessions))
}
}, [sessions])
useEffect(() => {
if (activeSessionId) {
localStorage.setItem(activeSessionStorageKey, activeSessionId)
}
}, [activeSessionId])
useEffect(() => {
localStorage.setItem(
studySettingsStorageKey,
JSON.stringify(studySettingsSnapshot),
)
}, [studySettingsSnapshot])
useEffect(() => {
if (!remoteWorkspaceEnabled || !workspaceHydrated.current || !sessions.length || !activeSessionId) {
if (!workspaceHydrated.current || !sessions.length || !activeSessionId) {
return
}
@@ -650,12 +467,17 @@ function App() {
}
remoteWorkspaceSaveTimeout.current = window.setTimeout(() => {
saveRemoteWorkspaceSnapshot({
saveWorkspaceSnapshot({
sessions,
activeSessionId,
studySettings: studySettingsSnapshot,
}).catch((reason) => {
console.warn('Unable to save hosted workspace.', reason)
console.warn(
hostedWorkspaceEnabled()
? 'Unable to save hosted workspace.'
: 'Unable to save local workspace.',
reason,
)
})
remoteWorkspaceSaveTimeout.current = null
}, 650)
+225
View File
@@ -0,0 +1,225 @@
export const timelineLimit = 200
export const defaultTranslationId = 'kjv-eng-kjv2006'
const sessionStorageKey = 'libre-study.sessions.v1'
const activeSessionStorageKey = 'libre-study.active-session.v1'
const studySettingsStorageKey = 'libre-study.study-settings.v1'
const remoteWorkspaceEnabled = import.meta.env.VITE_REMOTE_WORKSPACE === 'true'
const remoteWorkspaceApiBase = (import.meta.env.VITE_API_BASE_URL ?? '/api').replace(/\/$/, '')
export type ViewMode = 'page' | 'study' | 'notes' | 'compare'
export type StrongDisplayMode = 'off' | 'highlight' | 'numbers'
export type ThemeMode = 'light' | 'dark'
export type TimelineEntryType = 'verse' | 'strong' | 'footnote' | 'search' | 'navigation' | 'view' | 'setting'
export type TimelineEntry = {
id: string
at: string
type: TimelineEntryType
label: string
reference?: string
detail?: string
}
export type StudySession = {
id: string
title: string
baseTranslation: string
footnotesEnabled: boolean
strongsEnabled: boolean
commentaryEnabled: boolean
dictionaryEnabled: boolean
timelineEnabled: boolean
createdAt: string
updatedAt: string
selectedId: number | null
pageStartId: number | null
viewMode: ViewMode
strongDisplay: StrongDisplayMode
noteDraft: string
timeline: TimelineEntry[]
}
export type StudySettings = {
lemmaFontSize: number
linkedFontSize: number
originalCollapsed: boolean
linkedCollapsed: boolean
definitionCollapsed: boolean
studyNotesCollapsed: boolean
commentaryCollapsed: boolean
dictionaryCollapsed: boolean
showOriginalLine: boolean
showLinkedLine: boolean
showDefinitionPanel: boolean
showCommentaryPanel: boolean
showDictionaryPanel: boolean
splitPercent: number
notesPinned: boolean
themeMode: ThemeMode
}
export type WorkspaceSnapshot = {
sessions: StudySession[]
activeSessionId: string
studySettings: StudySettings
}
export function hostedWorkspaceEnabled() {
return remoteWorkspaceEnabled
}
export function defaultStudySettings(): StudySettings {
return {
lemmaFontSize: 17,
linkedFontSize: 15,
originalCollapsed: false,
linkedCollapsed: false,
definitionCollapsed: false,
studyNotesCollapsed: false,
commentaryCollapsed: false,
dictionaryCollapsed: false,
showOriginalLine: true,
showLinkedLine: true,
showDefinitionPanel: true,
showCommentaryPanel: true,
showDictionaryPanel: true,
splitPercent: 50,
notesPinned: false,
themeMode: 'light',
}
}
export function createSession(title = 'Study Session', baseTranslation = defaultTranslationId): 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,
baseTranslation,
footnotesEnabled: true,
strongsEnabled: true,
commentaryEnabled: true,
dictionaryEnabled: true,
timelineEnabled: true,
createdAt: now,
updatedAt: now,
selectedId: null,
pageStartId: null,
viewMode: 'notes',
strongDisplay: 'highlight',
noteDraft: '',
timeline: [],
}
}
export function timelineId() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
}
export function normalizeStudySettings(settings?: Partial<StudySettings> | null): StudySettings {
return {
...defaultStudySettings(),
...(settings ?? {}),
}
}
export function normalizeSessions(sessions?: StudySession[] | null): StudySession[] {
const source = Array.isArray(sessions) && sessions.length ? sessions : [createSession('KJV Study Session')]
return source.map((session) => ({
...createSession(session.title),
...session,
baseTranslation: session.baseTranslation ?? defaultTranslationId,
footnotesEnabled: session.footnotesEnabled ?? true,
strongsEnabled: session.strongsEnabled ?? true,
commentaryEnabled: session.commentaryEnabled ?? true,
dictionaryEnabled: session.dictionaryEnabled ?? true,
timelineEnabled: session.timelineEnabled ?? true,
}))
}
export function selectActiveSessionId(sessions: StudySession[], requestedId?: string | null): string {
if (!sessions.length) return ''
return sessions.some((session) => session.id === requestedId) ? requestedId ?? sessions[0].id : sessions[0].id
}
export async function loadWorkspaceSnapshot(): Promise<WorkspaceSnapshot> {
const remoteSnapshot = remoteWorkspaceEnabled ? await loadRemoteWorkspaceSnapshot() : null
const localSnapshot = remoteSnapshot ? null : loadLocalWorkspaceSnapshot()
const source = remoteSnapshot ?? localSnapshot
const sessions = normalizeSessions(source?.sessions)
const activeSessionId = selectActiveSessionId(sessions, source?.activeSessionId)
return {
sessions,
activeSessionId,
studySettings: normalizeStudySettings(source?.studySettings),
}
}
export function saveWorkspaceSnapshot(snapshot: WorkspaceSnapshot): Promise<void> {
saveLocalWorkspaceSnapshot(snapshot)
if (!remoteWorkspaceEnabled) return Promise.resolve()
return saveRemoteWorkspaceSnapshot(snapshot)
}
function loadLocalWorkspaceSnapshot(): Partial<WorkspaceSnapshot> {
return {
sessions: readJson<StudySession[]>(sessionStorageKey) ?? undefined,
activeSessionId: localStorage.getItem(activeSessionStorageKey) ?? undefined,
studySettings: readJson<StudySettings>(studySettingsStorageKey) ?? undefined,
}
}
function saveLocalWorkspaceSnapshot(snapshot: WorkspaceSnapshot) {
localStorage.setItem(sessionStorageKey, JSON.stringify(snapshot.sessions))
localStorage.setItem(activeSessionStorageKey, snapshot.activeSessionId)
localStorage.setItem(studySettingsStorageKey, JSON.stringify(snapshot.studySettings))
}
function readJson<T>(key: string): T | null {
const raw = localStorage.getItem(key)
if (!raw) return null
try {
return JSON.parse(raw) as T
} catch {
localStorage.removeItem(key)
return null
}
}
async function loadRemoteWorkspaceSnapshot(): Promise<Partial<WorkspaceSnapshot> | null> {
const response = await fetch(`${remoteWorkspaceApiBase}/workspace`, {
credentials: 'include',
})
if (response.status === 204 || response.status === 404) return null
if (response.status === 401) {
throw new Error('Login is required for the hosted workspace.')
}
if (!response.ok) {
throw new Error(`Unable to load hosted workspace: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<Partial<WorkspaceSnapshot>>
}
async function saveRemoteWorkspaceSnapshot(snapshot: WorkspaceSnapshot) {
const response = await fetch(`${remoteWorkspaceApiBase}/workspace`, {
method: 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(snapshot),
})
if (!response.ok) {
throw new Error(`Unable to save hosted workspace: ${response.status} ${response.statusText}`)
}
}