diff --git a/.gitignore b/.gitignore index dbddefa..0ad66e0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ lerna-debug.log* node_modules dist +dist-hosted dist-ssr src-tauri/target src-tauri/gen diff --git a/CHANGELOG.md b/CHANGELOG.md index f1739e2..e1b67f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.1.23 - 2026-07-12 + +- Added the hosted web build path for the private online alpha. +- Added a dependency-free Node web server that serves the production app, exposes LibreBible package files, and persists the hosted workspace through SQLite. +- Added hosted workspace synchronization for sessions, notes, settings, and timelines while keeping local preview storage unchanged. +- Added the first repeatable deployment script for pushing the built app, server, and LibreBible package snapshot to Christ Unscripted. + ## 0.1.22 - 2026-07-12 - Reordered the roadmap around a web-first path to get Libre Study online privately on Christ Unscripted. diff --git a/package-lock.json b/package-lock.json index 48d2cc1..2ec6ab9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "libre-study", - "version": "0.1.22", + "version": "0.1.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "libre-study", - "version": "0.1.22", + "version": "0.1.23", "dependencies": { "@tauri-apps/api": "^2.11.1", "react": "^19.2.7", diff --git a/package.json b/package.json index ac97451..513a04b 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,17 @@ { "name": "libre-study", "private": true, - "version": "0.1.22", + "version": "0.1.23", "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", "dev:web": "vite --host 127.0.0.1", "dev:tauri": "tauri dev", "build": "tsc -b && vite build", + "build:web:hosted": "tsc -b && vite build --base=/study/ --outDir dist-hosted", "lint": "oxlint", "preview": "vite preview", + "serve:web": "node server/libre-study-server.mjs", "tauri": "tauri" }, "dependencies": { diff --git a/scripts/deploy-web.ps1 b/scripts/deploy-web.ps1 new file mode 100644 index 0000000..9d830c2 --- /dev/null +++ b/scripts/deploy-web.ps1 @@ -0,0 +1,54 @@ +param( + [string]$HostName = "christunscripted", + [string]$RemoteRoot = "/sites/libre-study", + [string]$RemoteBibleDataRoot = "/sites/libre-bible-data/packages/json", + [int]$Port = 9174 +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$dataRepo = Resolve-Path (Join-Path $repoRoot "..\libre-bible-data") +$repoRootWsl = (wsl wslpath -a "$repoRoot").Trim() +$dataRepoWsl = (wsl wslpath -a "$dataRepo").Trim() +$commit = (git -C $repoRoot rev-parse HEAD).Trim() +$deployedAt = (Get-Date).ToUniversalTime().ToString("o") + +Push-Location $repoRoot +try { + $env:VITE_REMOTE_WORKSPACE = "true" + $env:VITE_API_BASE_URL = "/study/api" + $env:VITE_LIBREBIBLE_BASE_URL = "/study/librebible" + npm.cmd run build:web:hosted +} +finally { + Remove-Item Env:VITE_REMOTE_WORKSPACE -ErrorAction SilentlyContinue + Remove-Item Env:VITE_API_BASE_URL -ErrorAction SilentlyContinue + Remove-Item Env:VITE_LIBREBIBLE_BASE_URL -ErrorAction SilentlyContinue + Pop-Location +} + +$deployInfo = @{ + commit = $commit + deployedAt = $deployedAt + source = "libre-study" +} | ConvertTo-Json -Compress + +$escapedDeployInfo = $deployInfo.Replace("'", "'\''") +wsl ssh $HostName "mkdir -p '$RemoteRoot/app' '$RemoteRoot/server' '$RemoteRoot/state' '$RemoteBibleDataRoot' && printf '%s' '$escapedDeployInfo' > '$RemoteRoot/deploy-info.json'" +wsl rsync -az --delete "$repoRootWsl/dist-hosted/" "${HostName}:$RemoteRoot/app/" +wsl rsync -az --delete "$repoRootWsl/server/" "${HostName}:$RemoteRoot/server/" +wsl rsync -az --delete "$dataRepoWsl/packages/json/" "${HostName}:$RemoteBibleDataRoot/" + +$pm2Command = @" +cd '$RemoteRoot' && +if pm2 describe libre-study-web >/dev/null 2>&1; then + PORT=$Port PUBLIC_DIR='$RemoteRoot/app' LIBREBIBLE_DATA_DIR='$RemoteBibleDataRoot' STATE_DIR='$RemoteRoot/state' DB_PATH='$RemoteRoot/state/libre-study-web.sqlite3' DEPLOY_INFO_PATH='$RemoteRoot/deploy-info.json' pm2 restart libre-study-web --update-env; +else + PORT=$Port PUBLIC_DIR='$RemoteRoot/app' LIBREBIBLE_DATA_DIR='$RemoteBibleDataRoot' STATE_DIR='$RemoteRoot/state' DB_PATH='$RemoteRoot/state/libre-study-web.sqlite3' DEPLOY_INFO_PATH='$RemoteRoot/deploy-info.json' pm2 start '$RemoteRoot/server/libre-study-server.mjs' --name libre-study-web --update-env; +fi && +pm2 save +"@ + +wsl ssh $HostName $pm2Command +Write-Output "Deployed Libre Study web commit $commit to $HostName:$RemoteRoot on port $Port." diff --git a/server/libre-study-server.mjs b/server/libre-study-server.mjs new file mode 100644 index 0000000..28038f6 --- /dev/null +++ b/server/libre-study-server.mjs @@ -0,0 +1,204 @@ +import { createHash } from 'node:crypto' +import { createReadStream, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' +import { extname, join, normalize, resolve } from 'node:path' +import { execFileSync } from 'node:child_process' +import http from 'node:http' + +const port = Number(process.env.PORT ?? 9174) +const publicDir = resolve(process.env.PUBLIC_DIR ?? 'dist') +const dataDir = resolve(process.env.LIBREBIBLE_DATA_DIR ?? '../libre-bible-data/packages/json') +const stateDir = resolve(process.env.STATE_DIR ?? 'web-state') +const dbPath = resolve(process.env.DB_PATH ?? join(stateDir, 'libre-study-web.sqlite3')) +const deployInfoPath = process.env.DEPLOY_INFO_PATH ? resolve(process.env.DEPLOY_INFO_PATH) : '' +const sqliteBin = process.env.SQLITE_BIN ?? 'sqlite3' +const maxBodyBytes = Number(process.env.MAX_BODY_BYTES ?? 4 * 1024 * 1024) + +const mimeTypes = new Map([ + ['.css', 'text/css; charset=utf-8'], + ['.html', 'text/html; charset=utf-8'], + ['.ico', 'image/x-icon'], + ['.js', 'application/javascript; charset=utf-8'], + ['.json', 'application/json; charset=utf-8'], + ['.jsonl', 'application/x-ndjson; charset=utf-8'], + ['.map', 'application/json; charset=utf-8'], + ['.png', 'image/png'], + ['.svg', 'image/svg+xml'], + ['.txt', 'text/plain; charset=utf-8'], + ['.woff2', 'font/woff2'], +]) + +mkdirSync(stateDir, { recursive: true }) +sqlite(` +CREATE TABLE IF NOT EXISTS workspace_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + state_json TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +`) + +function sqlite(sql) { + const output = execFileSync(sqliteBin, ['-json', dbPath, sql], { + encoding: 'utf8', + maxBuffer: 1024 * 1024 * 20, + }) + return output.trim() ? JSON.parse(output) : [] +} + +function quoteSql(value) { + return `'${String(value).replaceAll("'", "''")}'` +} + +function sendJson(response, status, value) { + const body = JSON.stringify(value) + response.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + 'Cache-Control': 'no-store', + }) + response.end(body) +} + +function sendText(response, status, value) { + response.writeHead(status, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Length': Buffer.byteLength(value), + 'Cache-Control': 'no-store', + }) + response.end(value) +} + +function readBody(request) { + return new Promise((resolveBody, reject) => { + let size = 0 + const chunks = [] + request.on('data', (chunk) => { + size += chunk.length + if (size > maxBodyBytes) { + reject(new Error('Request body is too large.')) + request.destroy() + return + } + chunks.push(chunk) + }) + request.on('end', () => resolveBody(Buffer.concat(chunks).toString('utf8'))) + request.on('error', reject) + }) +} + +function safeFile(root, requestPath) { + const decoded = decodeURIComponent(requestPath) + const candidate = normalize(join(root, decoded)) + const rootWithSeparator = root.endsWith('\\') || root.endsWith('/') ? root : `${root}/` + if (candidate !== root && !candidate.startsWith(rootWithSeparator)) return null + return candidate +} + +function serveFile(response, filePath, cache = 'no-store') { + if (!existsSync(filePath) || !statSync(filePath).isFile()) return false + + const stat = statSync(filePath) + const contentType = mimeTypes.get(extname(filePath).toLowerCase()) ?? 'application/octet-stream' + response.writeHead(200, { + 'Content-Type': contentType, + 'Content-Length': stat.size, + 'Cache-Control': cache, + }) + createReadStream(filePath).pipe(response) + return true +} + +function serveStatic(response, requestPath) { + const pathWithoutSlash = requestPath.replace(/^\/+/, '') + const staticPath = safeFile(publicDir, pathWithoutSlash || 'index.html') + if (staticPath && serveFile(response, staticPath, requestPath.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-store')) { + return true + } + + return serveFile(response, join(publicDir, 'index.html'), 'no-store') +} + +function serveLibreBible(response, requestPath) { + const relative = requestPath.replace(/^\/librebible\/?/, '') + const filePath = safeFile(dataDir, relative) + if (!filePath || !serveFile(response, filePath, 'public, max-age=300')) { + sendText(response, 404, 'Not found') + } +} + +function deployInfo() { + if (!deployInfoPath || !existsSync(deployInfoPath)) return { commit: 'unknown' } + + try { + return JSON.parse(readFileSync(deployInfoPath, 'utf8')) + } catch { + return { commit: readFileSync(deployInfoPath, 'utf8').trim() || 'unknown' } + } +} + +async function handleApi(request, response, url) { + if (url.pathname === '/api/health') { + sendJson(response, 200, { ok: true, deploy: deployInfo() }) + return + } + + if (url.pathname === '/api/workspace' && request.method === 'GET') { + const rows = sqlite('SELECT state_json FROM workspace_state WHERE id = 1;') + if (!rows.length) { + response.writeHead(204, { 'Cache-Control': 'no-store' }) + response.end() + return + } + + response.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }) + response.end(rows[0].state_json) + return + } + + if (url.pathname === '/api/workspace' && request.method === 'PUT') { + const body = await readBody(request) + let parsed + try { + parsed = JSON.parse(body) + } catch { + sendJson(response, 400, { error: 'Workspace payload must be valid JSON.' }) + return + } + + sqlite( + `INSERT INTO workspace_state (id, state_json, updated_at) + VALUES (1, json(${quoteSql(JSON.stringify(parsed))}), CURRENT_TIMESTAMP) + ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = CURRENT_TIMESTAMP;`, + ) + sendJson(response, 200, { + ok: true, + checksum: createHash('sha256').update(JSON.stringify(parsed)).digest('hex'), + }) + return + } + + sendJson(response, 404, { error: 'Unknown API route.' }) +} + +const server = http.createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://localhost') + + Promise.resolve() + .then(() => { + if (url.pathname.startsWith('/api/')) return handleApi(request, response, url) + if (url.pathname.startsWith('/librebible/')) return serveLibreBible(response, url.pathname) + return serveStatic(response, url.pathname) + }) + .catch((error) => { + sendJson(response, 500, { error: String(error.message ?? error) }) + }) +}) + +server.listen(port, '127.0.0.1', () => { + console.log(`Libre Study web server listening on http://127.0.0.1:${port}`) + console.log(`Serving app from ${publicDir}`) + console.log(`Serving LibreBible data from ${dataDir}`) + console.log(`Persisting workspace to ${dbPath}`) +}) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 43f6157..a059cfb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "app" -version = "0.1.22" +version = "0.1.23" dependencies = [ "log", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 99f944a..9c04b2e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "0.1.22" +version = "0.1.23" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 26988c6..a3b9f0f 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.22", + "version": "0.1.23", "identifier": "org.librestudy.desktop", "build": { "frontendDist": "../dist", diff --git a/src/App.tsx b/src/App.tsx index b181828..a155eea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,8 @@ 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' @@ -53,6 +55,31 @@ type StudySession = { 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}` } @@ -102,6 +129,40 @@ function formatTimelineTime(value: string) { return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) } +async function loadRemoteWorkspaceSnapshot(): Promise | 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> +} + +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 = { Genesis: 'Gen', @@ -278,8 +339,31 @@ function App() { const restoredSessionId = useRef('') const pageMotionTimeout = useRef(null) const notesDragOpenTimeout = useRef(null) + const remoteWorkspaceSaveTimeout = useRef(null) const readerRef = useRef(null) const hydratedNoteChapters = useRef(new Set()) + const workspaceHydrated = useRef(false) + + function applyStudySettings(settings?: Partial) { + if (!settings) return + + setLemmaFontSize(settings.lemmaFontSize ?? 17) + setLinkedFontSize(settings.linkedFontSize ?? 15) + setOriginalCollapsed(settings.originalCollapsed ?? false) + setLinkedCollapsed(settings.linkedCollapsed ?? false) + setDefinitionCollapsed(settings.definitionCollapsed ?? false) + setStudyNotesCollapsed(settings.studyNotesCollapsed ?? false) + setCommentaryCollapsed(settings.commentaryCollapsed ?? false) + setDictionaryCollapsed(settings.dictionaryCollapsed ?? false) + setShowOriginalLine(settings.showOriginalLine ?? true) + setShowLinkedLine(settings.showLinkedLine ?? true) + setShowDefinitionPanel(settings.showDefinitionPanel ?? true) + setShowCommentaryPanel(settings.showCommentaryPanel ?? true) + setShowDictionaryPanel(settings.showDictionaryPanel ?? true) + setSplitPercent(settings.splitPercent ?? 50) + setNotesPinned(settings.notesPinned ?? false) + setThemeMode(settings.themeMode ?? 'light') + } async function loadLibrary(translationId = activeSession?.baseTranslation ?? defaultTranslationId) { setError(null) @@ -408,90 +492,133 @@ function App() { setTagInput('') } + const studySettingsSnapshot = useMemo( + () => ({ + lemmaFontSize, + linkedFontSize, + originalCollapsed, + linkedCollapsed, + definitionCollapsed, + studyNotesCollapsed, + commentaryCollapsed, + dictionaryCollapsed, + showOriginalLine, + showLinkedLine, + showDefinitionPanel, + showCommentaryPanel, + showDictionaryPanel, + splitPercent, + notesPinned, + themeMode, + }), + [ + commentaryCollapsed, + definitionCollapsed, + dictionaryCollapsed, + lemmaFontSize, + linkedCollapsed, + linkedFontSize, + notesPinned, + originalCollapsed, + showCommentaryPanel, + showDefinitionPanel, + showDictionaryPanel, + showLinkedLine, + showOriginalLine, + splitPercent, + studyNotesCollapsed, + themeMode, + ], + ) + useEffect(() => { - const savedStudySettings = localStorage.getItem(studySettingsStorageKey) - const savedSessions = localStorage.getItem(sessionStorageKey) - const savedActiveSessionId = localStorage.getItem(activeSessionStorageKey) - let nextSessions: StudySession[] = [] + let cancelled = false - if (savedStudySettings) { - try { - const parsed = JSON.parse(savedStudySettings) as Partial<{ - 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 - }> - setLemmaFontSize(parsed.lemmaFontSize ?? 17) - setLinkedFontSize(parsed.linkedFontSize ?? 15) - setOriginalCollapsed(parsed.originalCollapsed ?? false) - setLinkedCollapsed(parsed.linkedCollapsed ?? false) - setDefinitionCollapsed(parsed.definitionCollapsed ?? false) - setStudyNotesCollapsed(parsed.studyNotesCollapsed ?? false) - setCommentaryCollapsed(parsed.commentaryCollapsed ?? false) - setDictionaryCollapsed(parsed.dictionaryCollapsed ?? false) - setShowOriginalLine(parsed.showOriginalLine ?? true) - setShowLinkedLine(parsed.showLinkedLine ?? true) - setShowDefinitionPanel(parsed.showDefinitionPanel ?? true) - setShowCommentaryPanel(parsed.showCommentaryPanel ?? true) - setShowDictionaryPanel(parsed.showDictionaryPanel ?? true) - setSplitPercent(parsed.splitPercent ?? 50) - setNotesPinned(parsed.notesPinned ?? false) - setThemeMode(parsed.themeMode ?? 'light') - } catch { - localStorage.removeItem(studySettingsStorageKey) + async function restoreWorkspace() { + let remoteSnapshot: Partial | null = null + if (remoteWorkspaceEnabled) { + try { + remoteSnapshot = await loadRemoteWorkspaceSnapshot() + } catch (reason) { + setError(String(reason)) + } } - } - if (savedSessions) { - try { - const parsed = JSON.parse(savedSessions) as StudySession[] - nextSessions = Array.isArray(parsed) ? parsed : [] - } catch { - nextSessions = [] + 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) + } 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 selectedSession = + nextSessions.find((session) => session.id === selectedSessionId) ?? nextSessions[0] + + if (cancelled) return + setSessions(nextSessions) + setActiveSessionId(selectedSessionId) + getTranslations().then(setTranslations).catch((reason) => setError(String(reason))) + + loadLibrary(selectedSession.baseTranslation) + .catch((reason) => setError(String(reason))) + .finally(() => { + if (!cancelled) { + setLoading(false) + window.setTimeout(() => { + workspaceHydrated.current = true + }, 0) + } + }) } - if (!nextSessions.length) { - nextSessions = [createSession('KJV Study Session')] + restoreWorkspace().catch((reason) => { + if (!cancelled) { + setError(String(reason)) + setLoading(false) + } + }) + + return () => { + cancelled = true } - - 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 selectedSession = - nextSessions.find((session) => session.id === selectedSessionId) ?? nextSessions[0] - - setSessions(nextSessions) - setActiveSessionId(selectedSessionId) - getTranslations().then(setTranslations).catch((reason) => setError(String(reason))) - - loadLibrary(selectedSession.baseTranslation) - .catch((reason) => setError(String(reason))) - .finally(() => setLoading(false)) }, []) useEffect(() => { @@ -509,42 +636,40 @@ function App() { useEffect(() => { localStorage.setItem( studySettingsStorageKey, - JSON.stringify({ - lemmaFontSize, - linkedFontSize, - originalCollapsed, - linkedCollapsed, - definitionCollapsed, - studyNotesCollapsed, - commentaryCollapsed, - dictionaryCollapsed, - showOriginalLine, - showLinkedLine, - showDefinitionPanel, - showCommentaryPanel, - showDictionaryPanel, - splitPercent, - notesPinned, - themeMode, - }), + JSON.stringify(studySettingsSnapshot), ) + }, [studySettingsSnapshot]) + + useEffect(() => { + if (!remoteWorkspaceEnabled || !workspaceHydrated.current || !sessions.length || !activeSessionId) { + return + } + + if (remoteWorkspaceSaveTimeout.current) { + window.clearTimeout(remoteWorkspaceSaveTimeout.current) + } + + remoteWorkspaceSaveTimeout.current = window.setTimeout(() => { + saveRemoteWorkspaceSnapshot({ + sessions, + activeSessionId, + studySettings: studySettingsSnapshot, + }).catch((reason) => { + console.warn('Unable to save hosted workspace.', reason) + }) + remoteWorkspaceSaveTimeout.current = null + }, 650) + + return () => { + if (remoteWorkspaceSaveTimeout.current) { + window.clearTimeout(remoteWorkspaceSaveTimeout.current) + remoteWorkspaceSaveTimeout.current = null + } + } }, [ - commentaryCollapsed, - definitionCollapsed, - dictionaryCollapsed, - lemmaFontSize, - linkedCollapsed, - linkedFontSize, - notesPinned, - originalCollapsed, - showCommentaryPanel, - showDefinitionPanel, - showDictionaryPanel, - showLinkedLine, - showOriginalLine, - splitPercent, - studyNotesCollapsed, - themeMode, + activeSessionId, + sessions, + studySettingsSnapshot, ]) useEffect(() => { diff --git a/src/studyClient.ts b/src/studyClient.ts index 4b402e7..4e701da 100644 --- a/src/studyClient.ts +++ b/src/studyClient.ts @@ -16,7 +16,7 @@ type TauriWindow = Window & } const browserTagKey = 'libre-study-browser-tags' -const libreBibleDevRoot = '/@fs/W:/libre-bible-data/packages/json' +const libreBibleDevRoot = import.meta.env.VITE_LIBREBIBLE_BASE_URL ?? '/@fs/W:/libre-bible-data/packages/json' const defaultTranslationId = 'kjv-eng-kjv2006' type ImportedVerse = { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..518ab1e --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,11 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string + readonly VITE_LIBREBIBLE_BASE_URL?: string + readonly VITE_REMOTE_WORKSPACE?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +}