Extract shared workspace core
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.25 - 2026-07-12
|
||||||
|
|
||||||
|
- Started Phase 4 shared app core cleanup by extracting workspace/session/settings/timeline schema and persistence adapters into `src/workspace.ts`.
|
||||||
|
- Kept local preview and hosted workspace persistence behind the same load/save interface.
|
||||||
|
- Added the roadmap phase index so later resource-depth work such as maps, audio Bible, pronunciation audio, commentaries, dictionaries, lexicons, cross-references, and study notes remains visible.
|
||||||
|
|
||||||
## 0.1.24 - 2026-07-12
|
## 0.1.24 - 2026-07-12
|
||||||
|
|
||||||
- Fixed the hosted deploy script to use a separate `dist-hosted` output, user-writable remote runtime paths, reliable WSL path conversion, explicit rsync/PM2 failure checks, and rsynced deploy metadata.
|
- Fixed the hosted deploy script to use a separate `dist-hosted` output, user-writable remote runtime paths, reliable WSL path conversion, explicit rsync/PM2 failure checks, and rsynced deploy metadata.
|
||||||
|
|||||||
+14
@@ -41,6 +41,20 @@ Keep one application, not separate apps. Split the code by adapters:
|
|||||||
|
|
||||||
The hosted web version is the lead product lane and may add login and collaboration. The desktop version must remain usable without accounts when that lane resumes.
|
The hosted web version is the lead product lane and may add login and collaboration. The desktop version must remain usable without accounts when that lane resumes.
|
||||||
|
|
||||||
|
## Phase Index
|
||||||
|
|
||||||
|
- Phase 1: Private hosted preview.
|
||||||
|
- Phase 2: Server persistence and login.
|
||||||
|
- Phase 3: Deploy from Gitea.
|
||||||
|
- Phase 4: Shared app core.
|
||||||
|
- Phase 5: Storage and search foundation.
|
||||||
|
- Phase 6: Notes, exports, and Nextcloud.
|
||||||
|
- Phase 7: Study resource depth: maps, audio Bible, pronunciation audio, commentaries, dictionaries, lexicons, cross-references, and study notes.
|
||||||
|
- Phase 8: Collaboration.
|
||||||
|
- Phase 9: Desktop hardening.
|
||||||
|
- Phase 10: Public web release.
|
||||||
|
- Phase 11: Mobile and future platforms.
|
||||||
|
|
||||||
## Phase 1: Private Hosted Preview
|
## Phase 1: Private Hosted Preview
|
||||||
|
|
||||||
Goal: get the current working app online privately on Christ Unscripted as fast as possible and establish the hosted app as the main development target.
|
Goal: get the current working app online privately on Christ Unscripted as fast as possible and establish the hosted app as the main development target.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"version": "0.1.24",
|
"version": "0.1.25",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"version": "0.1.24",
|
"version": "0.1.25",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.24",
|
"version": "0.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 127.0.0.1",
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
|||||||
Generated
+1
-1
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.24"
|
version = "0.1.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.24"
|
version = "0.1.25"
|
||||||
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.24",
|
"version": "0.1.25",
|
||||||
"identifier": "org.librestudy.desktop",
|
"identifier": "org.librestudy.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
+30
-208
@@ -8,77 +8,30 @@ import {
|
|||||||
getTranslations,
|
getTranslations,
|
||||||
searchVerses,
|
searchVerses,
|
||||||
} from './studyClient'
|
} from './studyClient'
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
defaultTranslationId,
|
||||||
|
hostedWorkspaceEnabled,
|
||||||
|
loadWorkspaceSnapshot,
|
||||||
|
saveWorkspaceSnapshot,
|
||||||
|
timelineId,
|
||||||
|
timelineLimit,
|
||||||
|
} from './workspace'
|
||||||
import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
||||||
import type { CommentaryEntry, DictionaryEntry, TranslationResource, Verse } from './types'
|
import type { CommentaryEntry, DictionaryEntry, TranslationResource, Verse } from './types'
|
||||||
|
import type {
|
||||||
|
StrongDisplayMode,
|
||||||
|
StudySession,
|
||||||
|
StudySettings,
|
||||||
|
ThemeMode,
|
||||||
|
TimelineEntry,
|
||||||
|
ViewMode,
|
||||||
|
} from './workspace'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
|
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
|
||||||
const pageSize = 34
|
const pageSize = 34
|
||||||
const sessionStorageKey = 'libre-study.sessions.v1'
|
|
||||||
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'
|
type PageMotion = 'idle' | 'turn-forward' | 'turn-back'
|
||||||
type ThemeMode = 'light' | 'dark'
|
|
||||||
type TimelineEntryType = 'verse' | 'strong' | 'footnote' | 'search' | 'navigation' | 'view' | 'setting'
|
|
||||||
|
|
||||||
type TimelineEntry = {
|
|
||||||
id: string
|
|
||||||
at: string
|
|
||||||
type: TimelineEntryType
|
|
||||||
label: string
|
|
||||||
reference?: string
|
|
||||||
detail?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type StudySession = {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
baseTranslation: string
|
|
||||||
footnotesEnabled: boolean
|
|
||||||
strongsEnabled: boolean
|
|
||||||
commentaryEnabled: boolean
|
|
||||||
dictionaryEnabled: boolean
|
|
||||||
timelineEnabled: boolean
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
selectedId: number | null
|
|
||||||
pageStartId: number | null
|
|
||||||
viewMode: ViewMode
|
|
||||||
strongDisplay: StrongDisplayMode
|
|
||||||
noteDraft: string
|
|
||||||
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}`
|
||||||
@@ -94,75 +47,10 @@ function replaceChangedVerses(verses: Verse[], hydrate: (verse: Verse) => Verse)
|
|||||||
return changed ? next : verses
|
return changed ? next : verses
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSession(title = 'Study Session', baseTranslation = defaultTranslationId): StudySession {
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
const id =
|
|
||||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
baseTranslation,
|
|
||||||
footnotesEnabled: true,
|
|
||||||
strongsEnabled: true,
|
|
||||||
commentaryEnabled: true,
|
|
||||||
dictionaryEnabled: true,
|
|
||||||
timelineEnabled: true,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
selectedId: null,
|
|
||||||
pageStartId: null,
|
|
||||||
viewMode: 'notes',
|
|
||||||
strongDisplay: 'highlight',
|
|
||||||
noteDraft: '',
|
|
||||||
timeline: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function timelineId() {
|
|
||||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimelineTime(value: string) {
|
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',
|
||||||
@@ -535,66 +423,14 @@ function App() {
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
async function restoreWorkspace() {
|
async function restoreWorkspace() {
|
||||||
let remoteSnapshot: Partial<WorkspaceSnapshot> | null = null
|
const workspace = await loadWorkspaceSnapshot()
|
||||||
if (remoteWorkspaceEnabled) {
|
|
||||||
try {
|
|
||||||
remoteSnapshot = await loadRemoteWorkspaceSnapshot()
|
|
||||||
} catch (reason) {
|
|
||||||
setError(String(reason))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 =
|
const selectedSession =
|
||||||
nextSessions.find((session) => session.id === selectedSessionId) ?? nextSessions[0]
|
workspace.sessions.find((session) => session.id === workspace.activeSessionId) ?? workspace.sessions[0]
|
||||||
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setSessions(nextSessions)
|
applyStudySettings(workspace.studySettings)
|
||||||
setActiveSessionId(selectedSessionId)
|
setSessions(workspace.sessions)
|
||||||
|
setActiveSessionId(workspace.activeSessionId)
|
||||||
getTranslations().then(setTranslations).catch((reason) => setError(String(reason)))
|
getTranslations().then(setTranslations).catch((reason) => setError(String(reason)))
|
||||||
|
|
||||||
loadLibrary(selectedSession.baseTranslation)
|
loadLibrary(selectedSession.baseTranslation)
|
||||||
@@ -622,26 +458,7 @@ function App() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessions.length) {
|
if (!workspaceHydrated.current || !sessions.length || !activeSessionId) {
|
||||||
localStorage.setItem(sessionStorageKey, JSON.stringify(sessions))
|
|
||||||
}
|
|
||||||
}, [sessions])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeSessionId) {
|
|
||||||
localStorage.setItem(activeSessionStorageKey, activeSessionId)
|
|
||||||
}
|
|
||||||
}, [activeSessionId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(
|
|
||||||
studySettingsStorageKey,
|
|
||||||
JSON.stringify(studySettingsSnapshot),
|
|
||||||
)
|
|
||||||
}, [studySettingsSnapshot])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!remoteWorkspaceEnabled || !workspaceHydrated.current || !sessions.length || !activeSessionId) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,12 +467,17 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remoteWorkspaceSaveTimeout.current = window.setTimeout(() => {
|
remoteWorkspaceSaveTimeout.current = window.setTimeout(() => {
|
||||||
saveRemoteWorkspaceSnapshot({
|
saveWorkspaceSnapshot({
|
||||||
sessions,
|
sessions,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
studySettings: studySettingsSnapshot,
|
studySettings: studySettingsSnapshot,
|
||||||
}).catch((reason) => {
|
}).catch((reason) => {
|
||||||
console.warn('Unable to save hosted workspace.', reason)
|
console.warn(
|
||||||
|
hostedWorkspaceEnabled()
|
||||||
|
? 'Unable to save hosted workspace.'
|
||||||
|
: 'Unable to save local workspace.',
|
||||||
|
reason,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
remoteWorkspaceSaveTimeout.current = null
|
remoteWorkspaceSaveTimeout.current = null
|
||||||
}, 650)
|
}, 650)
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
export const timelineLimit = 200
|
||||||
|
export const defaultTranslationId = 'kjv-eng-kjv2006'
|
||||||
|
|
||||||
|
const sessionStorageKey = 'libre-study.sessions.v1'
|
||||||
|
const activeSessionStorageKey = 'libre-study.active-session.v1'
|
||||||
|
const studySettingsStorageKey = 'libre-study.study-settings.v1'
|
||||||
|
const remoteWorkspaceEnabled = import.meta.env.VITE_REMOTE_WORKSPACE === 'true'
|
||||||
|
const remoteWorkspaceApiBase = (import.meta.env.VITE_API_BASE_URL ?? '/api').replace(/\/$/, '')
|
||||||
|
|
||||||
|
export type ViewMode = 'page' | 'study' | 'notes' | 'compare'
|
||||||
|
export type StrongDisplayMode = 'off' | 'highlight' | 'numbers'
|
||||||
|
export type ThemeMode = 'light' | 'dark'
|
||||||
|
export type TimelineEntryType = 'verse' | 'strong' | 'footnote' | 'search' | 'navigation' | 'view' | 'setting'
|
||||||
|
|
||||||
|
export type TimelineEntry = {
|
||||||
|
id: string
|
||||||
|
at: string
|
||||||
|
type: TimelineEntryType
|
||||||
|
label: string
|
||||||
|
reference?: string
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StudySession = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
baseTranslation: string
|
||||||
|
footnotesEnabled: boolean
|
||||||
|
strongsEnabled: boolean
|
||||||
|
commentaryEnabled: boolean
|
||||||
|
dictionaryEnabled: boolean
|
||||||
|
timelineEnabled: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
selectedId: number | null
|
||||||
|
pageStartId: number | null
|
||||||
|
viewMode: ViewMode
|
||||||
|
strongDisplay: StrongDisplayMode
|
||||||
|
noteDraft: string
|
||||||
|
timeline: TimelineEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceSnapshot = {
|
||||||
|
sessions: StudySession[]
|
||||||
|
activeSessionId: string
|
||||||
|
studySettings: StudySettings
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostedWorkspaceEnabled() {
|
||||||
|
return remoteWorkspaceEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultStudySettings(): StudySettings {
|
||||||
|
return {
|
||||||
|
lemmaFontSize: 17,
|
||||||
|
linkedFontSize: 15,
|
||||||
|
originalCollapsed: false,
|
||||||
|
linkedCollapsed: false,
|
||||||
|
definitionCollapsed: false,
|
||||||
|
studyNotesCollapsed: false,
|
||||||
|
commentaryCollapsed: false,
|
||||||
|
dictionaryCollapsed: false,
|
||||||
|
showOriginalLine: true,
|
||||||
|
showLinkedLine: true,
|
||||||
|
showDefinitionPanel: true,
|
||||||
|
showCommentaryPanel: true,
|
||||||
|
showDictionaryPanel: true,
|
||||||
|
splitPercent: 50,
|
||||||
|
notesPinned: false,
|
||||||
|
themeMode: 'light',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSession(title = 'Study Session', baseTranslation = defaultTranslationId): StudySession {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const id =
|
||||||
|
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
baseTranslation,
|
||||||
|
footnotesEnabled: true,
|
||||||
|
strongsEnabled: true,
|
||||||
|
commentaryEnabled: true,
|
||||||
|
dictionaryEnabled: true,
|
||||||
|
timelineEnabled: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
selectedId: null,
|
||||||
|
pageStartId: null,
|
||||||
|
viewMode: 'notes',
|
||||||
|
strongDisplay: 'highlight',
|
||||||
|
noteDraft: '',
|
||||||
|
timeline: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timelineId() {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStudySettings(settings?: Partial<StudySettings> | null): StudySettings {
|
||||||
|
return {
|
||||||
|
...defaultStudySettings(),
|
||||||
|
...(settings ?? {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSessions(sessions?: StudySession[] | null): StudySession[] {
|
||||||
|
const source = Array.isArray(sessions) && sessions.length ? sessions : [createSession('KJV Study Session')]
|
||||||
|
|
||||||
|
return source.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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectActiveSessionId(sessions: StudySession[], requestedId?: string | null): string {
|
||||||
|
if (!sessions.length) return ''
|
||||||
|
return sessions.some((session) => session.id === requestedId) ? requestedId ?? sessions[0].id : sessions[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadWorkspaceSnapshot(): Promise<WorkspaceSnapshot> {
|
||||||
|
const remoteSnapshot = remoteWorkspaceEnabled ? await loadRemoteWorkspaceSnapshot() : null
|
||||||
|
const localSnapshot = remoteSnapshot ? null : loadLocalWorkspaceSnapshot()
|
||||||
|
const source = remoteSnapshot ?? localSnapshot
|
||||||
|
const sessions = normalizeSessions(source?.sessions)
|
||||||
|
const activeSessionId = selectActiveSessionId(sessions, source?.activeSessionId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
studySettings: normalizeStudySettings(source?.studySettings),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWorkspaceSnapshot(snapshot: WorkspaceSnapshot): Promise<void> {
|
||||||
|
saveLocalWorkspaceSnapshot(snapshot)
|
||||||
|
if (!remoteWorkspaceEnabled) return Promise.resolve()
|
||||||
|
return saveRemoteWorkspaceSnapshot(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLocalWorkspaceSnapshot(): Partial<WorkspaceSnapshot> {
|
||||||
|
return {
|
||||||
|
sessions: readJson<StudySession[]>(sessionStorageKey) ?? undefined,
|
||||||
|
activeSessionId: localStorage.getItem(activeSessionStorageKey) ?? undefined,
|
||||||
|
studySettings: readJson<StudySettings>(studySettingsStorageKey) ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLocalWorkspaceSnapshot(snapshot: WorkspaceSnapshot) {
|
||||||
|
localStorage.setItem(sessionStorageKey, JSON.stringify(snapshot.sessions))
|
||||||
|
localStorage.setItem(activeSessionStorageKey, snapshot.activeSessionId)
|
||||||
|
localStorage.setItem(studySettingsStorageKey, JSON.stringify(snapshot.studySettings))
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson<T>(key: string): T | null {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRemoteWorkspaceSnapshot(): Promise<Partial<WorkspaceSnapshot> | 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) {
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user