Extract shared workspace core
This commit is contained in:
+30
-208
@@ -8,77 +8,30 @@ import {
|
||||
getTranslations,
|
||||
searchVerses,
|
||||
} from './studyClient'
|
||||
import {
|
||||
createSession,
|
||||
defaultTranslationId,
|
||||
hostedWorkspaceEnabled,
|
||||
loadWorkspaceSnapshot,
|
||||
saveWorkspaceSnapshot,
|
||||
timelineId,
|
||||
timelineLimit,
|
||||
} from './workspace'
|
||||
import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
||||
import type { CommentaryEntry, DictionaryEntry, TranslationResource, Verse } from './types'
|
||||
import type {
|
||||
StrongDisplayMode,
|
||||
StudySession,
|
||||
StudySettings,
|
||||
ThemeMode,
|
||||
TimelineEntry,
|
||||
ViewMode,
|
||||
} from './workspace'
|
||||
import './App.css'
|
||||
|
||||
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
|
||||
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 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) {
|
||||
return `${verse.book} ${verse.chapter}:${verse.verse}`
|
||||
@@ -94,75 +47,10 @@ function replaceChangedVerses(verses: Verse[], hydrate: (verse: Verse) => Verse)
|
||||
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) {
|
||||
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',
|
||||
@@ -535,66 +423,14 @@ function App() {
|
||||
let cancelled = false
|
||||
|
||||
async function restoreWorkspace() {
|
||||
let remoteSnapshot: Partial<WorkspaceSnapshot> | null = null
|
||||
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 workspace = await loadWorkspaceSnapshot()
|
||||
const selectedSession =
|
||||
nextSessions.find((session) => session.id === selectedSessionId) ?? nextSessions[0]
|
||||
workspace.sessions.find((session) => session.id === workspace.activeSessionId) ?? workspace.sessions[0]
|
||||
|
||||
if (cancelled) return
|
||||
setSessions(nextSessions)
|
||||
setActiveSessionId(selectedSessionId)
|
||||
applyStudySettings(workspace.studySettings)
|
||||
setSessions(workspace.sessions)
|
||||
setActiveSessionId(workspace.activeSessionId)
|
||||
getTranslations().then(setTranslations).catch((reason) => setError(String(reason)))
|
||||
|
||||
loadLibrary(selectedSession.baseTranslation)
|
||||
@@ -622,26 +458,7 @@ function App() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (sessions.length) {
|
||||
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) {
|
||||
if (!workspaceHydrated.current || !sessions.length || !activeSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -650,12 +467,17 @@ function App() {
|
||||
}
|
||||
|
||||
remoteWorkspaceSaveTimeout.current = window.setTimeout(() => {
|
||||
saveRemoteWorkspaceSnapshot({
|
||||
saveWorkspaceSnapshot({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
studySettings: studySettingsSnapshot,
|
||||
}).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
|
||||
}, 650)
|
||||
|
||||
Reference in New Issue
Block a user