Add private hosted web alpha

This commit is contained in:
2026-07-12 12:10:39 -05:00
parent dad6ee4ff4
commit 6559295a64
12 changed files with 521 additions and 117 deletions
+235 -110
View File
@@ -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(() => {