Implement offline storage with IndexedDB for canvas documents
- Add @automerge/automerge-repo-storage-indexeddb for local persistence - Create documentIdMapping utility to track roomId → documentId in IndexedDB - Update useAutomergeSyncRepo with offline-first loading strategy: - Load from IndexedDB first for instant access - Sync with server in background when online - Track connection status (online/offline/syncing) - Add OfflineIndicator component to show connection state - Integrate offline indicator into Board component Documents are now cached locally and available offline. Automerge CRDT handles conflict resolution when syncing back online. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7919d34dfa
commit
6507adc36d
|
|
@ -13,6 +13,7 @@
|
|||
"@automerge/automerge": "^3.1.1",
|
||||
"@automerge/automerge-repo": "^2.2.0",
|
||||
"@automerge/automerge-repo-react-hooks": "^2.2.0",
|
||||
"@automerge/automerge-repo-storage-indexeddb": "^2.5.0",
|
||||
"@chengsokdara/use-whisper": "^0.2.0",
|
||||
"@daily-co/daily-js": "^0.60.0",
|
||||
"@daily-co/daily-react": "^0.20.0",
|
||||
|
|
@ -211,6 +212,31 @@
|
|||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@automerge/automerge-repo-storage-indexeddb": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@automerge/automerge-repo-storage-indexeddb/-/automerge-repo-storage-indexeddb-2.5.0.tgz",
|
||||
"integrity": "sha512-7MJYJ5S6K7dHlbvs5/u/v9iexqOeprU/qQonup28r2IoVqwzjuN5ezaoVk6JRBMDI/ZxWfU4rNrqVrVlB49yXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@automerge/automerge-repo": "2.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@automerge/automerge-repo-storage-indexeddb/node_modules/@automerge/automerge-repo": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@automerge/automerge-repo/-/automerge-repo-2.5.0.tgz",
|
||||
"integrity": "sha512-bdxuMuKmxw0ZjwQXecrIX1VrHXf445bYCftNJJ5vqgGWVvINB5ZKFYAbtgPIyu1Y0TXQKvc6eqESaDeL+g8MmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@automerge/automerge": "2.2.8 - 3",
|
||||
"bs58check": "^3.0.1",
|
||||
"cbor-x": "^1.3.0",
|
||||
"debug": "^4.3.4",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"fast-sha256": "^1.3.0",
|
||||
"uuid": "^9.0.0",
|
||||
"xstate": "^5.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
"@automerge/automerge": "^3.1.1",
|
||||
"@automerge/automerge-repo": "^2.2.0",
|
||||
"@automerge/automerge-repo-react-hooks": "^2.2.0",
|
||||
"@automerge/automerge-repo-storage-indexeddb": "^2.5.0",
|
||||
"@chengsokdara/use-whisper": "^0.2.0",
|
||||
"@daily-co/daily-js": "^0.60.0",
|
||||
"@daily-co/daily-react": "^0.20.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,250 @@
|
|||
/**
|
||||
* Document ID Mapping Utility
|
||||
*
|
||||
* Manages the mapping between room IDs (human-readable slugs) and
|
||||
* Automerge document IDs (automerge:xxxx format).
|
||||
*
|
||||
* This is necessary because:
|
||||
* - Automerge requires specific document ID formats
|
||||
* - We want to persist documents in IndexedDB with consistent IDs
|
||||
* - Room IDs are user-friendly slugs that may not match Automerge's format
|
||||
*/
|
||||
|
||||
const DB_NAME = 'canvas-document-mappings'
|
||||
const STORE_NAME = 'mappings'
|
||||
const DB_VERSION = 1
|
||||
|
||||
interface DocumentMapping {
|
||||
roomId: string
|
||||
documentId: string
|
||||
createdAt: number
|
||||
lastAccessedAt: number
|
||||
}
|
||||
|
||||
let dbInstance: IDBDatabase | null = null
|
||||
|
||||
/**
|
||||
* Open the IndexedDB database for document ID mappings
|
||||
*/
|
||||
async function openDatabase(): Promise<IDBDatabase> {
|
||||
if (dbInstance) return dbInstance
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('Failed to open document mapping database:', request.error)
|
||||
reject(request.error)
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
dbInstance = request.result
|
||||
resolve(request.result)
|
||||
}
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result
|
||||
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'roomId' })
|
||||
store.createIndex('documentId', 'documentId', { unique: true })
|
||||
store.createIndex('lastAccessedAt', 'lastAccessedAt', { unique: false })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Automerge document ID for a given room ID
|
||||
* Returns null if no mapping exists
|
||||
*/
|
||||
export async function getDocumentId(roomId: string): Promise<string | null> {
|
||||
try {
|
||||
const db = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
const request = store.get(roomId)
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('Failed to get document mapping:', request.error)
|
||||
reject(request.error)
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
const mapping = request.result as DocumentMapping | undefined
|
||||
if (mapping) {
|
||||
// Update last accessed time in background
|
||||
updateLastAccessed(roomId).catch(console.error)
|
||||
resolve(mapping.documentId)
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error getting document ID:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a mapping between room ID and Automerge document ID
|
||||
*/
|
||||
export async function saveDocumentId(roomId: string, documentId: string): Promise<void> {
|
||||
try {
|
||||
const db = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
|
||||
const mapping: DocumentMapping = {
|
||||
roomId,
|
||||
documentId,
|
||||
createdAt: Date.now(),
|
||||
lastAccessedAt: Date.now()
|
||||
}
|
||||
|
||||
const request = store.put(mapping)
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('Failed to save document mapping:', request.error)
|
||||
reject(request.error)
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log(`📝 Saved document mapping: ${roomId} → ${documentId}`)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error saving document ID:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last accessed timestamp for a room
|
||||
*/
|
||||
async function updateLastAccessed(roomId: string): Promise<void> {
|
||||
try {
|
||||
const db = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
const getRequest = store.get(roomId)
|
||||
|
||||
getRequest.onerror = () => reject(getRequest.error)
|
||||
|
||||
getRequest.onsuccess = () => {
|
||||
const mapping = getRequest.result as DocumentMapping | undefined
|
||||
if (mapping) {
|
||||
mapping.lastAccessedAt = Date.now()
|
||||
store.put(mapping)
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// Silent fail for background update
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document mapping (useful for cleanup)
|
||||
*/
|
||||
export async function deleteDocumentMapping(roomId: string): Promise<void> {
|
||||
try {
|
||||
const db = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
const request = store.delete(roomId)
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('Failed to delete document mapping:', request.error)
|
||||
reject(request.error)
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log(`🗑️ Deleted document mapping for: ${roomId}`)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting document mapping:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all document mappings (useful for debugging/management)
|
||||
*/
|
||||
export async function getAllMappings(): Promise<DocumentMapping[]> {
|
||||
try {
|
||||
const db = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
const request = store.getAll()
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('Failed to get all document mappings:', request.error)
|
||||
reject(request.error)
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result as DocumentMapping[])
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error getting all mappings:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old document mappings (documents not accessed in X days)
|
||||
* This helps manage storage quota
|
||||
*/
|
||||
export async function cleanupOldMappings(maxAgeDays: number = 30): Promise<number> {
|
||||
try {
|
||||
const db = await openDatabase()
|
||||
const cutoffTime = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
const index = store.index('lastAccessedAt')
|
||||
const range = IDBKeyRange.upperBound(cutoffTime)
|
||||
const request = index.openCursor(range)
|
||||
|
||||
let deletedCount = 0
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('Failed to cleanup old mappings:', request.error)
|
||||
reject(request.error)
|
||||
}
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result
|
||||
if (cursor) {
|
||||
cursor.delete()
|
||||
deletedCount++
|
||||
cursor.continue()
|
||||
} else {
|
||||
console.log(`🧹 Cleaned up ${deletedCount} old document mappings`)
|
||||
resolve(deletedCount)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up old mappings:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,9 @@ import { TLStoreSnapshot } from "@tldraw/tldraw"
|
|||
import { CloudflareNetworkAdapter } from "./CloudflareAdapter"
|
||||
import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2"
|
||||
import { TLStoreWithStatus } from "@tldraw/tldraw"
|
||||
import { Repo } from "@automerge/automerge-repo"
|
||||
import { DocHandle } from "@automerge/automerge-repo"
|
||||
import { Repo, DocHandle, DocumentId } from "@automerge/automerge-repo"
|
||||
import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb"
|
||||
import { getDocumentId, saveDocumentId } from "./documentIdMapping"
|
||||
|
||||
interface AutomergeSyncConfig {
|
||||
uri: string
|
||||
|
|
@ -17,9 +18,23 @@ interface AutomergeSyncConfig {
|
|||
}
|
||||
}
|
||||
|
||||
export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus & { handle: DocHandle<any> | null; presence: ReturnType<typeof useAutomergePresence> } {
|
||||
// Track online/offline status
|
||||
export type ConnectionStatus = 'online' | 'offline' | 'syncing'
|
||||
|
||||
// Return type for useAutomergeSync - extends TLStoreWithStatus with offline capabilities
|
||||
export interface AutomergeSyncResult {
|
||||
store?: TLStoreWithStatus['store']
|
||||
status: TLStoreWithStatus['status']
|
||||
error?: TLStoreWithStatus['error']
|
||||
handle: DocHandle<any> | null
|
||||
presence: ReturnType<typeof useAutomergePresence>
|
||||
connectionStatus: ConnectionStatus
|
||||
isOfflineReady: boolean
|
||||
}
|
||||
|
||||
export function useAutomergeSync(config: AutomergeSyncConfig): AutomergeSyncResult {
|
||||
const { uri, user } = config
|
||||
|
||||
|
||||
// Extract roomId from URI (e.g., "https://worker.com/connect/room123" -> "room123")
|
||||
const roomId = useMemo(() => {
|
||||
const match = uri.match(/\/connect\/([^\/]+)$/)
|
||||
|
|
@ -33,14 +48,18 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
|
||||
const [handle, setHandle] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>(
|
||||
typeof navigator !== 'undefined' && navigator.onLine ? 'online' : 'offline'
|
||||
)
|
||||
const [isOfflineReady, setIsOfflineReady] = useState(false)
|
||||
const handleRef = useRef<any>(null)
|
||||
const storeRef = useRef<any>(null)
|
||||
|
||||
|
||||
// Update refs when handle/store changes
|
||||
useEffect(() => {
|
||||
handleRef.current = handle
|
||||
}, [handle])
|
||||
|
||||
|
||||
// JSON sync is deprecated - all data now flows through Automerge sync protocol
|
||||
// Old format content is converted server-side and saved to R2 in Automerge format
|
||||
// This callback is kept for backwards compatibility but should not be used
|
||||
|
|
@ -49,92 +68,199 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
// Don't apply JSON sync - let Automerge sync handle everything
|
||||
return
|
||||
}, [])
|
||||
|
||||
|
||||
// Create Repo with both network AND storage adapters for offline support
|
||||
const [repo] = useState(() => {
|
||||
const adapter = new CloudflareNetworkAdapter(workerUrl, roomId, applyJsonSyncData)
|
||||
const networkAdapter = new CloudflareNetworkAdapter(workerUrl, roomId, applyJsonSyncData)
|
||||
const storageAdapter = new IndexedDBStorageAdapter()
|
||||
|
||||
console.log('🗄️ Creating Automerge Repo with IndexedDB storage adapter for offline support')
|
||||
|
||||
return new Repo({
|
||||
network: [adapter]
|
||||
network: [networkAdapter],
|
||||
storage: storageAdapter
|
||||
})
|
||||
})
|
||||
|
||||
// Initialize Automerge document handle
|
||||
// Listen for online/offline events
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
console.log('🌐 Network: Back online')
|
||||
setConnectionStatus('syncing')
|
||||
// The network adapter will automatically reconnect and sync
|
||||
// After a short delay, assume we're synced if no errors
|
||||
setTimeout(() => {
|
||||
setConnectionStatus('online')
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const handleOffline = () => {
|
||||
console.log('📴 Network: Gone offline')
|
||||
setConnectionStatus('offline')
|
||||
}
|
||||
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initialize Automerge document handle with offline-first approach
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
const initializeHandle = async () => {
|
||||
try {
|
||||
console.log("🔌 Initializing Automerge Repo with NetworkAdapter for room:", roomId)
|
||||
|
||||
if (mounted) {
|
||||
// CRITICAL: Create a new Automerge document (repo.create() generates a proper document ID)
|
||||
// We can't use repo.find() with a custom ID because Automerge requires specific document ID formats
|
||||
// Instead, we'll create a new document and load initial data from the server
|
||||
const handle = repo.create()
|
||||
|
||||
console.log("Created Automerge handle via Repo:", {
|
||||
handleId: handle.documentId,
|
||||
isReady: handle.isReady()
|
||||
})
|
||||
|
||||
// Wait for the handle to be ready
|
||||
console.log("🔌 Initializing Automerge Repo with offline support for room:", roomId)
|
||||
|
||||
if (!mounted) return
|
||||
|
||||
let handle: DocHandle<any>
|
||||
let existingDocId: string | null = null
|
||||
let loadedFromLocal = false
|
||||
|
||||
// Step 1: Check if we have a stored document ID for this room
|
||||
try {
|
||||
existingDocId = await getDocumentId(roomId)
|
||||
if (existingDocId) {
|
||||
console.log(`📦 Found existing document ID in IndexedDB: ${existingDocId}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Could not check IndexedDB for existing document:', error)
|
||||
}
|
||||
|
||||
// Step 2: Try to load from local storage first (offline-first approach)
|
||||
if (existingDocId) {
|
||||
try {
|
||||
console.log(`🔍 Attempting to load document from IndexedDB: ${existingDocId}`)
|
||||
// Use repo.find() which will check IndexedDB storage adapter first
|
||||
// In automerge-repo v2.x, find() can return a Promise
|
||||
const foundHandle = await Promise.resolve(repo.find(existingDocId as DocumentId))
|
||||
handle = foundHandle as DocHandle<any>
|
||||
|
||||
// Wait for the handle to be ready (will load from IndexedDB if available)
|
||||
await handle.whenReady()
|
||||
|
||||
const localDoc = handle.doc() as any
|
||||
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
|
||||
|
||||
if (localRecordCount > 0) {
|
||||
console.log(`✅ Loaded ${localRecordCount} records from IndexedDB (offline-first)`)
|
||||
loadedFromLocal = true
|
||||
setIsOfflineReady(true)
|
||||
} else {
|
||||
console.log('📦 Document exists in IndexedDB but is empty')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Could not load from IndexedDB, will create new document:', error)
|
||||
existingDocId = null
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: If no local document, create a new one
|
||||
if (!existingDocId || !handle!) {
|
||||
console.log('📝 Creating new Automerge document')
|
||||
handle = repo.create()
|
||||
|
||||
// Save the mapping for future offline access
|
||||
await saveDocumentId(roomId, handle.documentId)
|
||||
console.log(`📝 Saved new document mapping: ${roomId} → ${handle.documentId}`)
|
||||
|
||||
await handle.whenReady()
|
||||
|
||||
// CRITICAL: Always load initial data from the server
|
||||
// The server stores documents in R2 as JSON, so we need to load and initialize the Automerge document
|
||||
console.log("📥 Loading initial data from server...")
|
||||
}
|
||||
|
||||
// Step 4: Sync with server if online (background sync)
|
||||
if (navigator.onLine) {
|
||||
setConnectionStatus('syncing')
|
||||
console.log("📥 Syncing with server...")
|
||||
|
||||
try {
|
||||
const response = await fetch(`${workerUrl}/room/${roomId}`)
|
||||
if (response.ok) {
|
||||
const serverDoc = await response.json() as TLStoreSnapshot
|
||||
const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
const serverRecordCount = Object.keys(serverDoc.store || {}).length
|
||||
|
||||
console.log(`📥 Loaded document from server: ${serverRecordCount} records, ${serverShapeCount} shapes`)
|
||||
|
||||
// Initialize the Automerge document with server data
|
||||
const serverShapeCount = serverDoc.store
|
||||
? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||
: 0
|
||||
|
||||
console.log(`📥 Server has: ${serverRecordCount} records, ${serverShapeCount} shapes`)
|
||||
|
||||
// Merge server data into local document
|
||||
if (serverDoc.store && serverRecordCount > 0) {
|
||||
handle.change((doc: any) => {
|
||||
// Initialize store if it doesn't exist
|
||||
if (!doc.store) {
|
||||
doc.store = {}
|
||||
}
|
||||
// Copy all records from server document
|
||||
Object.entries(serverDoc.store).forEach(([id, record]) => {
|
||||
doc.store[id] = record
|
||||
const localDoc = handle.doc() as any
|
||||
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
|
||||
|
||||
// If server has more data or local is empty, merge server data
|
||||
if (serverRecordCount > 0) {
|
||||
handle.change((doc: any) => {
|
||||
if (!doc.store) {
|
||||
doc.store = {}
|
||||
}
|
||||
// Merge server records (Automerge will handle conflicts)
|
||||
Object.entries(serverDoc.store).forEach(([id, record]) => {
|
||||
// Only add if not already present locally, or if this is first load
|
||||
if (!doc.store[id] || !loadedFromLocal) {
|
||||
doc.store[id] = record
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
console.log(`✅ Initialized Automerge document with ${serverRecordCount} records from server`)
|
||||
} else {
|
||||
console.log("📥 Server document is empty - starting with empty Automerge document")
|
||||
|
||||
const mergedDoc = handle.doc() as any
|
||||
const mergedCount = mergedDoc?.store ? Object.keys(mergedDoc.store).length : 0
|
||||
console.log(`✅ Merged server data. Total records: ${mergedCount}`)
|
||||
}
|
||||
} else if (response.status !== 404) {
|
||||
console.log("📥 Server document is empty")
|
||||
}
|
||||
|
||||
setConnectionStatus('online')
|
||||
} else if (response.status === 404) {
|
||||
console.log("📥 No document found on server (404) - starting with empty document")
|
||||
console.log("📥 No document on server yet - local document will be synced when saved")
|
||||
setConnectionStatus('online')
|
||||
} else {
|
||||
console.warn(`⚠️ Failed to load document from server: ${response.status} ${response.statusText}`)
|
||||
console.warn(`⚠️ Server sync failed: ${response.status}`)
|
||||
setConnectionStatus(loadedFromLocal ? 'offline' : 'online')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Error loading initial document from server:", error)
|
||||
// Continue anyway - user can still create new content
|
||||
console.error("❌ Error syncing with server:", error)
|
||||
// If we loaded from local, we're still functional in offline mode
|
||||
setConnectionStatus(loadedFromLocal ? 'offline' : 'online')
|
||||
}
|
||||
|
||||
const finalDoc = handle.doc() as any
|
||||
const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
||||
const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
|
||||
console.log("Automerge handle initialized:", {
|
||||
hasDoc: !!finalDoc,
|
||||
storeKeys: finalStoreKeys,
|
||||
shapeCount: finalShapeCount
|
||||
})
|
||||
|
||||
} else {
|
||||
console.log("📴 Offline - using local data only")
|
||||
setConnectionStatus('offline')
|
||||
}
|
||||
|
||||
// Mark as offline-ready once we have any document loaded
|
||||
setIsOfflineReady(true)
|
||||
|
||||
const finalDoc = handle.doc() as any
|
||||
const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
||||
const finalShapeCount = finalDoc?.store
|
||||
? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||
: 0
|
||||
|
||||
console.log("✅ Automerge handle initialized:", {
|
||||
documentId: handle.documentId,
|
||||
hasDoc: !!finalDoc,
|
||||
storeKeys: finalStoreKeys,
|
||||
shapeCount: finalShapeCount,
|
||||
loadedFromLocal,
|
||||
isOnline: navigator.onLine
|
||||
})
|
||||
|
||||
if (mounted) {
|
||||
setHandle(handle)
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initializing Automerge handle:", error)
|
||||
console.error("❌ Error initializing Automerge handle:", error)
|
||||
if (mounted) {
|
||||
setIsLoading(false)
|
||||
setConnectionStatus('offline')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -144,7 +270,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [repo, roomId])
|
||||
}, [repo, roomId, workerUrl])
|
||||
|
||||
// Auto-save to Cloudflare on every change (with debouncing to prevent excessive calls)
|
||||
// CRITICAL: This ensures new shapes are persisted to R2
|
||||
|
|
@ -279,6 +405,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
return {
|
||||
...storeWithStatus,
|
||||
handle,
|
||||
presence
|
||||
presence,
|
||||
connectionStatus,
|
||||
isOfflineReady
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import { ConnectionStatus } from '@/automerge/useAutomergeSyncRepo'
|
||||
|
||||
interface OfflineIndicatorProps {
|
||||
connectionStatus: ConnectionStatus
|
||||
isOfflineReady: boolean
|
||||
}
|
||||
|
||||
export function OfflineIndicator({ connectionStatus, isOfflineReady }: OfflineIndicatorProps) {
|
||||
// Don't show indicator when online and everything is working normally
|
||||
if (connectionStatus === 'online') {
|
||||
return null
|
||||
}
|
||||
|
||||
const getStatusConfig = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'offline':
|
||||
return {
|
||||
icon: '📴',
|
||||
text: isOfflineReady ? 'Offline (changes saved locally)' : 'Offline',
|
||||
bgColor: '#fef3c7', // warm yellow
|
||||
textColor: '#92400e',
|
||||
borderColor: '#f59e0b'
|
||||
}
|
||||
case 'syncing':
|
||||
return {
|
||||
icon: '🔄',
|
||||
text: 'Syncing...',
|
||||
bgColor: '#dbeafe', // light blue
|
||||
textColor: '#1e40af',
|
||||
borderColor: '#3b82f6'
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const config = getStatusConfig()
|
||||
if (!config) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: config.bgColor,
|
||||
color: config.textColor,
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${config.borderColor}`,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
zIndex: 9999,
|
||||
fontSize: '14px',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>{config.icon}</span>
|
||||
<span>{config.text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@ import { Collection, initializeGlobalCollections } from "@/collections"
|
|||
import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection"
|
||||
import { GestureTool } from "@/GestureTool"
|
||||
import { CmdK } from "@/CmdK"
|
||||
import { OfflineIndicator } from "@/components/OfflineIndicator"
|
||||
|
||||
|
||||
import "react-cmdk/dist/cmdk.css"
|
||||
|
|
@ -203,13 +204,14 @@ export function Board() {
|
|||
|
||||
// Use Automerge sync for all environments
|
||||
const storeWithHandle = useAutomergeSync(storeConfig)
|
||||
const store = {
|
||||
store: storeWithHandle.store,
|
||||
const store = {
|
||||
store: storeWithHandle.store,
|
||||
status: storeWithHandle.status,
|
||||
...('connectionStatus' in storeWithHandle ? { connectionStatus: storeWithHandle.connectionStatus } : {}),
|
||||
error: storeWithHandle.error
|
||||
}
|
||||
const automergeHandle = (storeWithHandle as any).handle
|
||||
const automergeHandle = storeWithHandle.handle
|
||||
const connectionStatus = storeWithHandle.connectionStatus
|
||||
const isOfflineReady = storeWithHandle.isOfflineReady
|
||||
const [editor, setEditor] = useState<Editor | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -658,6 +660,7 @@ export function Board() {
|
|||
<div style={{ position: "fixed", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div>Loading canvas...</div>
|
||||
</div>
|
||||
<OfflineIndicator connectionStatus={connectionStatus} isOfflineReady={isOfflineReady} />
|
||||
</AutomergeHandleProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -740,6 +743,7 @@ export function Board() {
|
|||
>
|
||||
<CmdK />
|
||||
</Tldraw>
|
||||
<OfflineIndicator connectionStatus={connectionStatus} isOfflineReady={isOfflineReady} />
|
||||
</div>
|
||||
</AutomergeHandleProvider>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue