Add private hosted web alpha
This commit is contained in:
+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