Add private hosted web alpha
This commit is contained in:
@@ -9,6 +9,7 @@ lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-hosted
|
||||
dist-ssr
|
||||
src-tauri/target
|
||||
src-tauri/gen
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+3
-1
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
@@ -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}`)
|
||||
})
|
||||
Generated
+1
-1
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "app"
|
||||
version = "0.1.22"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rusqlite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.22"
|
||||
version = "0.1.23"
|
||||
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.22",
|
||||
"version": "0.1.23",
|
||||
"identifier": "org.librestudy.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+235
-110
@@ -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<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',
|
||||
@@ -278,8 +339,31 @@ function App() {
|
||||
const restoredSessionId = useRef('')
|
||||
const pageMotionTimeout = useRef<number | null>(null)
|
||||
const notesDragOpenTimeout = useRef<number | null>(null)
|
||||
const remoteWorkspaceSaveTimeout = useRef<number | null>(null)
|
||||
const readerRef = useRef<HTMLElement | null>(null)
|
||||
const hydratedNoteChapters = useRef(new Set<string>())
|
||||
const workspaceHydrated = useRef(false)
|
||||
|
||||
function applyStudySettings(settings?: Partial<StudySettings>) {
|
||||
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<StudySettings>(
|
||||
() => ({
|
||||
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<WorkspaceSnapshot> | 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<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 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(() => {
|
||||
|
||||
+1
-1
@@ -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 = {
|
||||
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
readonly VITE_LIBREBIBLE_BASE_URL?: string
|
||||
readonly VITE_REMOTE_WORKSPACE?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user