Add private hosted web alpha
This commit is contained in:
@@ -9,6 +9,7 @@ lerna-debug.log*
|
|||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
dist-hosted
|
||||||
dist-ssr
|
dist-ssr
|
||||||
src-tauri/target
|
src-tauri/target
|
||||||
src-tauri/gen
|
src-tauri/gen
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.22 - 2026-07-12
|
||||||
|
|
||||||
- Reordered the roadmap around a web-first path to get Libre Study online privately on Christ Unscripted.
|
- 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",
|
"name": "libre-study",
|
||||||
"version": "0.1.22",
|
"version": "0.1.23",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"version": "0.1.22",
|
"version": "0.1.23",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
|
|||||||
+3
-1
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.22",
|
"version": "0.1.23",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 127.0.0.1",
|
"dev": "vite --host 127.0.0.1",
|
||||||
"dev:web": "vite --host 127.0.0.1",
|
"dev:web": "vite --host 127.0.0.1",
|
||||||
"dev:tauri": "tauri dev",
|
"dev:tauri": "tauri dev",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"build:web:hosted": "tsc -b && vite build --base=/study/ --outDir dist-hosted",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"serve:web": "node server/libre-study-server.mjs",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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]]
|
[[package]]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.22"
|
version = "0.1.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.22"
|
version = "0.1.23"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
license = ""
|
license = ""
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "Libre Study",
|
"productName": "Libre Study",
|
||||||
"version": "0.1.22",
|
"version": "0.1.23",
|
||||||
"identifier": "org.librestudy.desktop",
|
"identifier": "org.librestudy.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
+235
-110
@@ -19,6 +19,8 @@ const activeSessionStorageKey = 'libre-study.active-session.v1'
|
|||||||
const studySettingsStorageKey = 'libre-study.study-settings.v1'
|
const studySettingsStorageKey = 'libre-study.study-settings.v1'
|
||||||
const timelineLimit = 200
|
const timelineLimit = 200
|
||||||
const defaultTranslationId = 'kjv-eng-kjv2006'
|
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 ViewMode = 'page' | 'study' | 'notes' | 'compare'
|
||||||
type StrongDisplayMode = 'off' | 'highlight' | 'numbers'
|
type StrongDisplayMode = 'off' | 'highlight' | 'numbers'
|
||||||
type PageMotion = 'idle' | 'turn-forward' | 'turn-back'
|
type PageMotion = 'idle' | 'turn-forward' | 'turn-back'
|
||||||
@@ -53,6 +55,31 @@ type StudySession = {
|
|||||||
timeline: TimelineEntry[]
|
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) {
|
function reference(verse: Verse) {
|
||||||
return `${verse.book} ${verse.chapter}:${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' })
|
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) {
|
function abbreviateBook(book: string) {
|
||||||
const abbreviations: Record<string, string> = {
|
const abbreviations: Record<string, string> = {
|
||||||
Genesis: 'Gen',
|
Genesis: 'Gen',
|
||||||
@@ -278,8 +339,31 @@ function App() {
|
|||||||
const restoredSessionId = useRef('')
|
const restoredSessionId = useRef('')
|
||||||
const pageMotionTimeout = useRef<number | null>(null)
|
const pageMotionTimeout = useRef<number | null>(null)
|
||||||
const notesDragOpenTimeout = useRef<number | null>(null)
|
const notesDragOpenTimeout = useRef<number | null>(null)
|
||||||
|
const remoteWorkspaceSaveTimeout = useRef<number | null>(null)
|
||||||
const readerRef = useRef<HTMLElement | null>(null)
|
const readerRef = useRef<HTMLElement | null>(null)
|
||||||
const hydratedNoteChapters = useRef(new Set<string>())
|
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) {
|
async function loadLibrary(translationId = activeSession?.baseTranslation ?? defaultTranslationId) {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -408,90 +492,133 @@ function App() {
|
|||||||
setTagInput('')
|
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(() => {
|
useEffect(() => {
|
||||||
const savedStudySettings = localStorage.getItem(studySettingsStorageKey)
|
let cancelled = false
|
||||||
const savedSessions = localStorage.getItem(sessionStorageKey)
|
|
||||||
const savedActiveSessionId = localStorage.getItem(activeSessionStorageKey)
|
|
||||||
let nextSessions: StudySession[] = []
|
|
||||||
|
|
||||||
if (savedStudySettings) {
|
async function restoreWorkspace() {
|
||||||
try {
|
let remoteSnapshot: Partial<WorkspaceSnapshot> | null = null
|
||||||
const parsed = JSON.parse(savedStudySettings) as Partial<{
|
if (remoteWorkspaceEnabled) {
|
||||||
lemmaFontSize: number
|
try {
|
||||||
linkedFontSize: number
|
remoteSnapshot = await loadRemoteWorkspaceSnapshot()
|
||||||
originalCollapsed: boolean
|
} catch (reason) {
|
||||||
linkedCollapsed: boolean
|
setError(String(reason))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (savedSessions) {
|
const savedStudySettings = remoteSnapshot?.studySettings
|
||||||
try {
|
? JSON.stringify(remoteSnapshot.studySettings)
|
||||||
const parsed = JSON.parse(savedSessions) as StudySession[]
|
: localStorage.getItem(studySettingsStorageKey)
|
||||||
nextSessions = Array.isArray(parsed) ? parsed : []
|
const savedSessions = remoteSnapshot?.sessions
|
||||||
} catch {
|
? JSON.stringify(remoteSnapshot.sessions)
|
||||||
nextSessions = []
|
: 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) {
|
restoreWorkspace().catch((reason) => {
|
||||||
nextSessions = [createSession('KJV Study Session')]
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -509,42 +636,40 @@ function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
studySettingsStorageKey,
|
studySettingsStorageKey,
|
||||||
JSON.stringify({
|
JSON.stringify(studySettingsSnapshot),
|
||||||
lemmaFontSize,
|
|
||||||
linkedFontSize,
|
|
||||||
originalCollapsed,
|
|
||||||
linkedCollapsed,
|
|
||||||
definitionCollapsed,
|
|
||||||
studyNotesCollapsed,
|
|
||||||
commentaryCollapsed,
|
|
||||||
dictionaryCollapsed,
|
|
||||||
showOriginalLine,
|
|
||||||
showLinkedLine,
|
|
||||||
showDefinitionPanel,
|
|
||||||
showCommentaryPanel,
|
|
||||||
showDictionaryPanel,
|
|
||||||
splitPercent,
|
|
||||||
notesPinned,
|
|
||||||
themeMode,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
}, [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,
|
activeSessionId,
|
||||||
definitionCollapsed,
|
sessions,
|
||||||
dictionaryCollapsed,
|
studySettingsSnapshot,
|
||||||
lemmaFontSize,
|
|
||||||
linkedCollapsed,
|
|
||||||
linkedFontSize,
|
|
||||||
notesPinned,
|
|
||||||
originalCollapsed,
|
|
||||||
showCommentaryPanel,
|
|
||||||
showDefinitionPanel,
|
|
||||||
showDictionaryPanel,
|
|
||||||
showLinkedLine,
|
|
||||||
showOriginalLine,
|
|
||||||
splitPercent,
|
|
||||||
studyNotesCollapsed,
|
|
||||||
themeMode,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ type TauriWindow = Window &
|
|||||||
}
|
}
|
||||||
|
|
||||||
const browserTagKey = 'libre-study-browser-tags'
|
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'
|
const defaultTranslationId = 'kjv-eng-kjv2006'
|
||||||
|
|
||||||
type ImportedVerse = {
|
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