Jaimodiji commited on
Commit
0fe06eb
·
verified ·
1 Parent(s): edf291b

Upload folder using huggingface_hub

Browse files
.build_counter CHANGED
@@ -1 +1 @@
1
- 20
 
1
+ 21
public/scripts/LiveSync.js CHANGED
@@ -210,9 +210,15 @@ export class LiveSyncClient {
210
  if (typeof event.data === 'string') {
211
  const msg = JSON.parse(event.data);
212
  this._handleYjsMessage(msg);
 
 
 
213
  }
214
  } catch (err) {
215
- console.error('[Yjs] Message parse error:', err);
 
 
 
216
  }
217
  };
218
 
@@ -401,7 +407,11 @@ export class LiveSyncClient {
401
  color: msg.color,
402
  size: msg.size
403
  });
404
- console.log(`[Yjs] Received presence from ${msg.clientId}: page ${msg.pageIdx}`);
 
 
 
 
405
 
406
  // Follow mode: Navigate to the same page as the other user
407
  // (Skip if this is an echo of our own change or if we recently changed pages)
 
210
  if (typeof event.data === 'string') {
211
  const msg = JSON.parse(event.data);
212
  this._handleYjsMessage(msg);
213
+ } else if (event.data instanceof Blob) {
214
+ // Binary message - could be Yjs update
215
+ // Ignore for now as we use JSON-based sync
216
  }
217
  } catch (err) {
218
+ // Only log actual parse errors, not empty objects
219
+ if (event.data && event.data.length > 2) {
220
+ console.warn('[Yjs] Message parse error:', err.message);
221
+ }
222
  }
223
  };
224
 
 
407
  color: msg.color,
408
  size: msg.size
409
  });
410
+ // Throttle presence logging to reduce console spam
411
+ if (!this._lastPresenceLogTime || Date.now() - this._lastPresenceLogTime > 2000) {
412
+ console.log(`[Yjs] Presence from ${msg.clientId}: page ${msg.pageIdx}`);
413
+ this._lastPresenceLogTime = Date.now();
414
+ }
415
 
416
  // Follow mode: Navigate to the same page as the other user
417
  // (Skip if this is an echo of our own change or if we recently changed pages)
public/scripts/UI.js CHANGED
@@ -1,8 +1,41 @@
1
  export const UI = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  showDashboard: () => {
3
  const db = document.getElementById('dashboardModal');
4
  if (db) db.style.display='flex';
5
- if (window.App && window.App.loadSessionList) window.App.loadSessionList();
 
 
 
 
 
 
6
  },
7
  hideDashboard: () => {
8
  const db = document.getElementById('dashboardModal');
@@ -11,7 +44,17 @@ export const UI = {
11
  showExportModal: () => {
12
  const em = document.getElementById('exportModal');
13
  if (em) em.style.display='flex';
14
- if (window.App && window.App.renderDlGrid) window.App.renderDlGrid();
 
 
 
 
 
 
 
 
 
 
15
 
16
  // Load persisted export preferences
17
  try {
@@ -117,10 +160,21 @@ export const UI = {
117
  if (msgEl) msgEl.innerText = message;
118
  modal.style.display = 'flex';
119
 
 
 
 
 
 
 
 
 
 
 
120
  const cleanup = () => {
121
  modal.style.display = 'none';
122
  okBtn.onclick = null;
123
  cancelBtn.onclick = null;
 
124
  };
125
 
126
  okBtn.onclick = () => {
@@ -133,6 +187,9 @@ export const UI = {
133
  if (onCancel) onCancel();
134
  resolve(false);
135
  };
 
 
 
136
  });
137
  },
138
  showAlert: (title, message) => {
@@ -162,10 +219,22 @@ export const UI = {
162
  if (msgEl) msgEl.innerText = message;
163
  modal.style.display = 'flex';
164
 
 
 
 
 
 
 
 
 
165
  okBtn.onclick = () => {
166
  modal.style.display = 'none';
 
167
  resolve();
168
  };
 
 
 
169
  });
170
  },
171
  showPrompt: (title, placeholder, defaultValue = '') => {
 
1
  export const UI = {
2
+ // Mini loading indicator for quick operations (non-blocking)
3
+ showMiniLoader: (text = 'Loading...') => {
4
+ let mini = document.getElementById('miniLoader');
5
+ if (!mini) {
6
+ mini = document.createElement('div');
7
+ mini.id = 'miniLoader';
8
+ mini.style.cssText = 'position:fixed;top:10px;right:10px;background:rgba(0,0,0,0.8);color:white;padding:8px 16px;border-radius:6px;font-size:13px;z-index:9999;display:flex;align-items:center;gap:8px;';
9
+ mini.innerHTML = '<span class="spinner" style="width:14px;height:14px;border:2px solid rgba(255,255,255,0.3);border-top-color:white;border-radius:50%;animation:spin 0.8s linear infinite;"></span><span id="miniLoaderText"></span>';
10
+ // Add spinner animation if not exists
11
+ if (!document.getElementById('miniLoaderStyle')) {
12
+ const style = document.createElement('style');
13
+ style.id = 'miniLoaderStyle';
14
+ style.textContent = '@keyframes spin{to{transform:rotate(360deg)}}';
15
+ document.head.appendChild(style);
16
+ }
17
+ document.body.appendChild(mini);
18
+ }
19
+ const textEl = mini.querySelector('#miniLoaderText');
20
+ if (textEl) textEl.textContent = text;
21
+ mini.style.display = 'flex';
22
+ },
23
+
24
+ hideMiniLoader: () => {
25
+ const mini = document.getElementById('miniLoader');
26
+ if (mini) mini.style.display = 'none';
27
+ },
28
+
29
  showDashboard: () => {
30
  const db = document.getElementById('dashboardModal');
31
  if (db) db.style.display='flex';
32
+ // Show loading indicator while loading sessions
33
+ UI.showMiniLoader('Loading projects...');
34
+ if (window.App && window.App.loadSessionList) {
35
+ window.App.loadSessionList().finally(() => UI.hideMiniLoader());
36
+ } else {
37
+ UI.hideMiniLoader();
38
+ }
39
  },
40
  hideDashboard: () => {
41
  const db = document.getElementById('dashboardModal');
 
44
  showExportModal: () => {
45
  const em = document.getElementById('exportModal');
46
  if (em) em.style.display='flex';
47
+ // Show loading indicator while preparing export grid
48
+ UI.showMiniLoader('Loading pages...');
49
+ if (window.App && window.App.renderDlGrid) {
50
+ try {
51
+ window.App.renderDlGrid();
52
+ } finally {
53
+ UI.hideMiniLoader();
54
+ }
55
+ } else {
56
+ UI.hideMiniLoader();
57
+ }
58
 
59
  // Load persisted export preferences
60
  try {
 
160
  if (msgEl) msgEl.innerText = message;
161
  modal.style.display = 'flex';
162
 
163
+ // Keyboard handler for accessibility
164
+ const keyHandler = (e) => {
165
+ if (e.key === 'Escape') {
166
+ cancelBtn.click();
167
+ } else if (e.key === 'Enter') {
168
+ okBtn.click();
169
+ }
170
+ };
171
+ document.addEventListener('keydown', keyHandler);
172
+
173
  const cleanup = () => {
174
  modal.style.display = 'none';
175
  okBtn.onclick = null;
176
  cancelBtn.onclick = null;
177
+ document.removeEventListener('keydown', keyHandler);
178
  };
179
 
180
  okBtn.onclick = () => {
 
187
  if (onCancel) onCancel();
188
  resolve(false);
189
  };
190
+
191
+ // Focus confirm button for keyboard access
192
+ okBtn.focus();
193
  });
194
  },
195
  showAlert: (title, message) => {
 
219
  if (msgEl) msgEl.innerText = message;
220
  modal.style.display = 'flex';
221
 
222
+ // Keyboard handler for accessibility
223
+ const keyHandler = (e) => {
224
+ if (e.key === 'Escape' || e.key === 'Enter') {
225
+ okBtn.click();
226
+ }
227
+ };
228
+ document.addEventListener('keydown', keyHandler);
229
+
230
  okBtn.onclick = () => {
231
  modal.style.display = 'none';
232
+ document.removeEventListener('keydown', keyHandler);
233
  resolve();
234
  };
235
+
236
+ // Focus OK button for keyboard access
237
+ okBtn.focus();
238
  });
239
  },
240
  showPrompt: (title, placeholder, defaultValue = '') => {
public/scripts/modules/ColorRmExport.js CHANGED
@@ -28,7 +28,12 @@ export const ColorRmExport = {
28
  this.state.dlSelection.sort((a,b)=>a-b);
29
  el.className = `thumb-item ${this.state.dlSelection.includes(i)?'selected':''}`;
30
  };
31
- const im = new Image(); im.src = URL.createObjectURL(img.blob);
 
 
 
 
 
32
  el.appendChild(im);
33
  const sp = document.createElement('span'); sp.innerText = i+1; el.appendChild(sp);
34
  g.appendChild(el);
 
28
  this.state.dlSelection.sort((a,b)=>a-b);
29
  el.className = `thumb-item ${this.state.dlSelection.includes(i)?'selected':''}`;
30
  };
31
+ const im = new Image();
32
+ const blobUrl = URL.createObjectURL(img.blob);
33
+ im.src = blobUrl;
34
+ // Revoke blob URL after image loads to prevent memory leak
35
+ im.onload = () => URL.revokeObjectURL(blobUrl);
36
+ im.onerror = () => URL.revokeObjectURL(blobUrl);
37
  el.appendChild(im);
38
  const sp = document.createElement('span'); sp.innerText = i+1; el.appendChild(sp);
39
  g.appendChild(el);
public/scripts/modules/ColorRmInput.js CHANGED
@@ -375,14 +375,32 @@ export const ColorRmInput = {
375
  let isDragging = false; let startX, startY, initLeft, initTop;
376
  const handle = this.getElement('pickerDragHandle');
377
  if(handle) {
 
 
 
 
378
  handle.onmousedown = (e) => { isDragging = true; startX = e.clientX; startY = e.clientY; const r = el.getBoundingClientRect(); initLeft = r.left; initTop = r.top; };
379
- // Use document instead of window to be more contained
380
- // Each instance's isDragging flag prevents cross-instance interference
381
- document.addEventListener('mousemove', (e) => { if(!isDragging) return; el.style.left = (initLeft + (e.clientX - startX)) + 'px'; el.style.top = (initTop + (e.clientY - startY)) + 'px'; });
382
- document.addEventListener('mouseup', () => isDragging = false);
 
 
 
 
383
  }
384
  },
385
 
 
 
 
 
 
 
 
 
 
 
386
  setupShortcuts() {
387
  const target = this.container || document;
388
 
@@ -1335,10 +1353,12 @@ export const ColorRmInput = {
1335
  };
1336
 
1337
  window.addEventListener('pointermove', onPointerMove, { passive: true });
 
 
1338
 
1339
  // Only main app listens to window resize for cursor re-rendering
1340
  if (this.config.isMain) {
1341
- window.addEventListener('resize', () => {
1342
  if (this.liveSync && this.liveSync.renderCursors) this.liveSync.renderCursors();
1343
 
1344
  // Update infinite canvas dimensions on resize
@@ -1355,7 +1375,9 @@ export const ColorRmInput = {
1355
  this.render();
1356
  }
1357
  }
1358
- });
 
 
1359
  }
1360
  const vp = this.getElement('viewport');
1361
  if(vp) vp.addEventListener('scroll', () => this.liveSync && this.liveSync.renderCursors && this.liveSync.renderCursors(), { passive: true });
 
375
  let isDragging = false; let startX, startY, initLeft, initTop;
376
  const handle = this.getElement('pickerDragHandle');
377
  if(handle) {
378
+ // Store event handlers for cleanup
379
+ const onMouseMove = (e) => { if(!isDragging) return; el.style.left = (initLeft + (e.clientX - startX)) + 'px'; el.style.top = (initTop + (e.clientY - startY)) + 'px'; };
380
+ const onMouseUp = () => isDragging = false;
381
+
382
  handle.onmousedown = (e) => { isDragging = true; startX = e.clientX; startY = e.clientY; const r = el.getBoundingClientRect(); initLeft = r.left; initTop = r.top; };
383
+ document.addEventListener('mousemove', onMouseMove);
384
+ document.addEventListener('mouseup', onMouseUp);
385
+
386
+ // Store cleanup function for later
387
+ this._draggableCleanup = () => {
388
+ document.removeEventListener('mousemove', onMouseMove);
389
+ document.removeEventListener('mouseup', onMouseUp);
390
+ };
391
  }
392
  },
393
 
394
+ // Cleanup event listeners (call when destroying app instance)
395
+ cleanup() {
396
+ if (this._draggableCleanup) this._draggableCleanup();
397
+ if (this._pointerMoveCleanup) this._pointerMoveCleanup();
398
+ if (this._pointerUpCleanup) this._pointerUpCleanup();
399
+ if (this._resizeCleanup) this._resizeCleanup();
400
+ if (this._spenEngineCleanup) this._spenEngineCleanup();
401
+ console.log('[ColorRmInput] Event listeners cleaned up');
402
+ },
403
+
404
  setupShortcuts() {
405
  const target = this.container || document;
406
 
 
1353
  };
1354
 
1355
  window.addEventListener('pointermove', onPointerMove, { passive: true });
1356
+ // Store cleanup function
1357
+ this._pointerMoveCleanup = () => window.removeEventListener('pointermove', onPointerMove);
1358
 
1359
  // Only main app listens to window resize for cursor re-rendering
1360
  if (this.config.isMain) {
1361
+ const onResize = () => {
1362
  if (this.liveSync && this.liveSync.renderCursors) this.liveSync.renderCursors();
1363
 
1364
  // Update infinite canvas dimensions on resize
 
1375
  this.render();
1376
  }
1377
  }
1378
+ };
1379
+ window.addEventListener('resize', onResize);
1380
+ this._resizeCleanup = () => window.removeEventListener('resize', onResize);
1381
  }
1382
  const vp = this.getElement('viewport');
1383
  if(vp) vp.addEventListener('scroll', () => this.liveSync && this.liveSync.renderCursors && this.liveSync.renderCursors(), { passive: true });
public/scripts/modules/ColorRmRenderer.js CHANGED
@@ -1036,28 +1036,63 @@ export const ColorRmRenderer = {
1036
  return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
1037
  },
1038
 
1039
- // Image cache for loaded images
1040
  _imageCache: new Map(),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1041
 
1042
  // Helper to render images (v2 feature)
1043
  _renderImage(ctx, st) {
1044
  const cacheKey = st.src;
1045
  let img = this._imageCache.get(cacheKey);
1046
 
 
 
 
 
1047
  if (!img) {
1048
  // Load image asynchronously
1049
  img = new Image();
1050
  img.onload = () => {
1051
- this._imageCache.set(cacheKey, img);
1052
  img._loaded = true;
1053
  // Trigger re-render
1054
  this.invalidateCache();
1055
  };
1056
  img.onerror = () => {
1057
  console.warn('Failed to load image:', st.src?.substring(0, 50));
 
 
 
 
1058
  };
1059
  img.src = st.src;
1060
- this._imageCache.set(cacheKey, img);
1061
  return; // Don't render until loaded
1062
  }
1063
 
 
1036
  return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
1037
  },
1038
 
1039
+ // Image cache for loaded images with size limit to prevent memory leaks
1040
  _imageCache: new Map(),
1041
+ _imageCacheOrder: [], // LRU tracking
1042
+ _imageCacheMaxSize: 50, // Maximum cached images
1043
+
1044
+ // Manage image cache with LRU eviction
1045
+ _addToImageCache(key, img) {
1046
+ // Remove oldest entries if at limit
1047
+ while (this._imageCache.size >= this._imageCacheMaxSize && this._imageCacheOrder.length > 0) {
1048
+ const oldestKey = this._imageCacheOrder.shift();
1049
+ this._imageCache.delete(oldestKey);
1050
+ }
1051
+ this._imageCache.set(key, img);
1052
+ this._imageCacheOrder.push(key);
1053
+ },
1054
+
1055
+ _touchImageCache(key) {
1056
+ const idx = this._imageCacheOrder.indexOf(key);
1057
+ if (idx > -1) {
1058
+ this._imageCacheOrder.splice(idx, 1);
1059
+ this._imageCacheOrder.push(key);
1060
+ }
1061
+ },
1062
+
1063
+ // Clear image cache (call when switching pages or low memory)
1064
+ clearImageCache() {
1065
+ this._imageCache.clear();
1066
+ this._imageCacheOrder = [];
1067
+ },
1068
 
1069
  // Helper to render images (v2 feature)
1070
  _renderImage(ctx, st) {
1071
  const cacheKey = st.src;
1072
  let img = this._imageCache.get(cacheKey);
1073
 
1074
+ if (img) {
1075
+ this._touchImageCache(cacheKey); // Update LRU
1076
+ }
1077
+
1078
  if (!img) {
1079
  // Load image asynchronously
1080
  img = new Image();
1081
  img.onload = () => {
1082
+ this._addToImageCache(cacheKey, img);
1083
  img._loaded = true;
1084
  // Trigger re-render
1085
  this.invalidateCache();
1086
  };
1087
  img.onerror = () => {
1088
  console.warn('Failed to load image:', st.src?.substring(0, 50));
1089
+ // Show error to user for important images
1090
+ if (this.ui && this.ui.showToast) {
1091
+ this.ui.showToast('Failed to load image');
1092
+ }
1093
  };
1094
  img.src = st.src;
1095
+ this._addToImageCache(cacheKey, img);
1096
  return; // Don't render until loaded
1097
  }
1098
 
public/scripts/modules/ColorRmSession.js CHANGED
@@ -1744,9 +1744,11 @@ export const ColorRmSession = {
1744
  for (let j = 0; j < BATCH_SIZE && (i + j) <= pdf.numPages; j++) {
1745
  const pNum = i + j;
1746
  batch.push(pdf.getPage(pNum).then(async page => {
 
 
1747
  const v = page.getViewport({
1748
- scale: 1.5
1749
- }); // Increased scale for higher quality
1750
  const cvs = document.createElement('canvas');
1751
  cvs.width = v.width;
1752
  cvs.height = v.height;
@@ -1754,7 +1756,8 @@ export const ColorRmSession = {
1754
  canvasContext: cvs.getContext('2d'),
1755
  viewport: v
1756
  }).promise;
1757
- const b = await new Promise(r => cvs.toBlob(r, 'image/jpeg', 0.9)); // Higher quality JPEG
 
1758
  const pageIdx = idx + j;
1759
  const pageId = this._generatePageId('pdf', pNum - 1); // PDF pages are 1-indexed, we want 0-indexed
1760
  const pageObj = {
@@ -1766,8 +1769,14 @@ export const ColorRmSession = {
1766
  history: []
1767
  };
1768
  await this.dbPut('pages', pageObj);
1769
- // NOTE: PDF pages are NOT uploaded individually - base PDF is uploaded once
1770
- // and clients render pages from it. Only user-added pages go to R2.
 
 
 
 
 
 
1771
  return pageObj;
1772
  }));
1773
  }
 
1744
  for (let j = 0; j < BATCH_SIZE && (i + j) <= pdf.numPages; j++) {
1745
  const pNum = i + j;
1746
  batch.push(pdf.getPage(pNum).then(async page => {
1747
+ // 3x DPI (216 DPI) for high-quality rasterization
1748
+ const PDF_RENDER_SCALE = 3.0;
1749
  const v = page.getViewport({
1750
+ scale: PDF_RENDER_SCALE
1751
+ });
1752
  const cvs = document.createElement('canvas');
1753
  cvs.width = v.width;
1754
  cvs.height = v.height;
 
1756
  canvasContext: cvs.getContext('2d'),
1757
  viewport: v
1758
  }).promise;
1759
+ // High quality JPEG (0.92) for good compression with minimal artifacts
1760
+ const b = await new Promise(r => cvs.toBlob(r, 'image/jpeg', 0.92));
1761
  const pageIdx = idx + j;
1762
  const pageId = this._generatePageId('pdf', pNum - 1); // PDF pages are 1-indexed, we want 0-indexed
1763
  const pageObj = {
 
1769
  history: []
1770
  };
1771
  await this.dbPut('pages', pageObj);
1772
+
1773
+ // Upload rasterized PDF page to R2 for multi-user sync
1774
+ try {
1775
+ await this._uploadPageBlob(pageId, b);
1776
+ } catch (uploadErr) {
1777
+ console.warn(`[PDF Import] Failed to upload page ${pageId} to R2:`, uploadErr);
1778
+ }
1779
+
1780
  return pageObj;
1781
  }));
1782
  }
public/scripts/modules/ColorRmStorage.js CHANGED
@@ -1,9 +1,54 @@
 
 
1
  export const ColorRmStorage = {
2
- async dbPut(s, v) { return new Promise(r=>{const t=this.db.transaction(s,'readwrite'); t.objectStore(s).put(v); t.oncomplete=()=>r()}); },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
- async dbGet(s, k) { return new Promise(r=>{const q=this.db.transaction(s,'readonly').objectStore(s).get(k);q.onsuccess=()=>r(q.result)}); },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- async dbGetAll(s) { return new Promise(r=>{const q=this.db.transaction(s,'readonly').objectStore(s).getAll();q.onsuccess=()=>r(q.result||[]);q.onerror=()=>r([])}); },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  async saveSessionState() {
9
  if(!this.state.sessionId || (this.liveSync && this.liveSync.isInitializing) || this.isUploading) return;
@@ -159,14 +204,18 @@ export const ColorRmStorage = {
159
  const modsUrl = window.Config?.apiUrl(`/api/color_rm/modifications/${this.state.sessionId}/${page.pageId}`)
160
  || `/api/color_rm/modifications/${this.state.sessionId}/${page.pageId}`;
161
 
162
- await fetch(modsUrl, {
163
  method: 'POST',
164
  headers: { 'Content-Type': 'application/json' },
165
  body: JSON.stringify({
166
  modifications: m,
167
  timestamp: Date.now()
168
  })
169
- });
 
 
 
 
170
 
171
  // Only sync deltas via Liveblocks (usually small)
172
  // Also notify that modifications are in R2
@@ -213,11 +262,15 @@ export const ColorRmStorage = {
213
  const historyUrl = window.Config?.apiUrl(`/api/color_rm/history/${this.state.sessionId}/${pageId}`)
214
  || `/api/color_rm/history/${this.state.sessionId}/${pageId}`;
215
 
216
- await fetch(historyUrl, {
217
  method: 'POST',
218
  headers: { 'Content-Type': 'application/json' },
219
  body: JSON.stringify(currentPage.history)
220
- });
 
 
 
 
221
 
222
  // Mark page as having base history in R2
223
  currentPage.hasBaseHistory = true;
 
1
+ import { fetchWithTimeout, TIMEOUT } from './NetworkUtils.js';
2
+
3
  export const ColorRmStorage = {
4
+ async dbPut(s, v) {
5
+ return new Promise((resolve, reject) => {
6
+ try {
7
+ const t = this.db.transaction(s, 'readwrite');
8
+ t.objectStore(s).put(v);
9
+ t.oncomplete = () => resolve();
10
+ t.onerror = (e) => {
11
+ console.error(`[dbPut] Error saving to ${s}:`, e.target.error);
12
+ reject(e.target.error);
13
+ };
14
+ } catch (e) {
15
+ console.error(`[dbPut] Transaction error:`, e);
16
+ reject(e);
17
+ }
18
+ });
19
+ },
20
 
21
+ async dbGet(s, k) {
22
+ return new Promise((resolve, reject) => {
23
+ try {
24
+ const q = this.db.transaction(s, 'readonly').objectStore(s).get(k);
25
+ q.onsuccess = () => resolve(q.result);
26
+ q.onerror = (e) => {
27
+ console.error(`[dbGet] Error reading from ${s}:`, e.target.error);
28
+ resolve(null); // Return null on error to avoid breaking flows
29
+ };
30
+ } catch (e) {
31
+ console.error(`[dbGet] Transaction error:`, e);
32
+ resolve(null);
33
+ }
34
+ });
35
+ },
36
 
37
+ async dbGetAll(s) {
38
+ return new Promise((resolve) => {
39
+ try {
40
+ const q = this.db.transaction(s, 'readonly').objectStore(s).getAll();
41
+ q.onsuccess = () => resolve(q.result || []);
42
+ q.onerror = (e) => {
43
+ console.error(`[dbGetAll] Error reading all from ${s}:`, e.target.error);
44
+ resolve([]);
45
+ };
46
+ } catch (e) {
47
+ console.error(`[dbGetAll] Transaction error:`, e);
48
+ resolve([]);
49
+ }
50
+ });
51
+ },
52
 
53
  async saveSessionState() {
54
  if(!this.state.sessionId || (this.liveSync && this.liveSync.isInitializing) || this.isUploading) return;
 
204
  const modsUrl = window.Config?.apiUrl(`/api/color_rm/modifications/${this.state.sessionId}/${page.pageId}`)
205
  || `/api/color_rm/modifications/${this.state.sessionId}/${page.pageId}`;
206
 
207
+ const response = await fetchWithTimeout(modsUrl, {
208
  method: 'POST',
209
  headers: { 'Content-Type': 'application/json' },
210
  body: JSON.stringify({
211
  modifications: m,
212
  timestamp: Date.now()
213
  })
214
+ }, TIMEOUT.MEDIUM);
215
+
216
+ if (!response.ok) {
217
+ throw new Error(`Upload failed: ${response.status}`);
218
+ }
219
 
220
  // Only sync deltas via Liveblocks (usually small)
221
  // Also notify that modifications are in R2
 
262
  const historyUrl = window.Config?.apiUrl(`/api/color_rm/history/${this.state.sessionId}/${pageId}`)
263
  || `/api/color_rm/history/${this.state.sessionId}/${pageId}`;
264
 
265
+ const response = await fetchWithTimeout(historyUrl, {
266
  method: 'POST',
267
  headers: { 'Content-Type': 'application/json' },
268
  body: JSON.stringify(currentPage.history)
269
+ }, TIMEOUT.LONG);
270
+
271
+ if (!response.ok) {
272
+ throw new Error(`Upload failed: ${response.status}`);
273
+ }
274
 
275
  // Mark page as having base history in R2
276
  currentPage.hasBaseHistory = true;
public/scripts/modules/NetworkUtils.js ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Network Utilities - Shared helpers for network operations
3
+ * Provides timeout-enabled fetch and consistent error handling
4
+ */
5
+
6
+ /**
7
+ * Fetch with timeout - wraps fetch with AbortController timeout
8
+ * @param {string} url - The URL to fetch
9
+ * @param {object} options - Fetch options
10
+ * @param {number} timeoutMs - Timeout in milliseconds (default: 30000)
11
+ * @returns {Promise<Response>}
12
+ */
13
+ export async function fetchWithTimeout(url, options = {}, timeoutMs = 30000) {
14
+ const controller = new AbortController();
15
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
16
+
17
+ try {
18
+ const response = await fetch(url, {
19
+ ...options,
20
+ signal: controller.signal
21
+ });
22
+ clearTimeout(timeoutId);
23
+ return response;
24
+ } catch (error) {
25
+ clearTimeout(timeoutId);
26
+ if (error.name === 'AbortError') {
27
+ throw new Error(`Request timeout after ${timeoutMs}ms: ${url}`);
28
+ }
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Fetch with retry - retries failed requests with exponential backoff
35
+ * @param {string} url - The URL to fetch
36
+ * @param {object} options - Fetch options
37
+ * @param {number} maxRetries - Max retry attempts (default: 3)
38
+ * @param {number} baseDelayMs - Base delay between retries (default: 1000)
39
+ * @param {number} timeoutMs - Timeout per request (default: 30000)
40
+ * @param {function} onRetry - Optional callback(attempt, error) called before each retry
41
+ * @returns {Promise<Response>}
42
+ */
43
+ export async function fetchWithRetry(url, options = {}, maxRetries = 3, baseDelayMs = 1000, timeoutMs = 30000, onRetry = null) {
44
+ let lastError;
45
+
46
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
47
+ try {
48
+ return await fetchWithTimeout(url, options, timeoutMs);
49
+ } catch (error) {
50
+ lastError = error;
51
+
52
+ if (attempt < maxRetries) {
53
+ const delay = baseDelayMs * Math.pow(2, attempt);
54
+ if (onRetry) {
55
+ onRetry(attempt + 1, error);
56
+ }
57
+ await new Promise(r => setTimeout(r, delay));
58
+ }
59
+ }
60
+ }
61
+
62
+ throw lastError;
63
+ }
64
+
65
+ /**
66
+ * Upload with progress - performs upload with progress callback
67
+ * Uses XMLHttpRequest for progress events
68
+ * @param {string} url - The URL to upload to
69
+ * @param {FormData|Blob|string} data - The data to upload
70
+ * @param {object} options - Options including headers, method, timeout
71
+ * @param {function} onProgress - Progress callback(percent, loaded, total)
72
+ * @returns {Promise<{status: number, response: any}>}
73
+ */
74
+ export function uploadWithProgress(url, data, options = {}, onProgress = null) {
75
+ return new Promise((resolve, reject) => {
76
+ const xhr = new XMLHttpRequest();
77
+ const method = options.method || 'POST';
78
+ const timeout = options.timeout || 120000; // 2 minute default for uploads
79
+
80
+ xhr.open(method, url);
81
+
82
+ // Set headers
83
+ if (options.headers) {
84
+ for (const [key, value] of Object.entries(options.headers)) {
85
+ xhr.setRequestHeader(key, value);
86
+ }
87
+ }
88
+
89
+ // Set timeout
90
+ xhr.timeout = timeout;
91
+
92
+ // Progress handler
93
+ if (onProgress && xhr.upload) {
94
+ xhr.upload.onprogress = (e) => {
95
+ if (e.lengthComputable) {
96
+ const percent = (e.loaded / e.total) * 100;
97
+ onProgress(percent, e.loaded, e.total);
98
+ }
99
+ };
100
+ }
101
+
102
+ xhr.onload = () => {
103
+ if (xhr.status >= 200 && xhr.status < 300) {
104
+ try {
105
+ const response = xhr.responseText ? JSON.parse(xhr.responseText) : null;
106
+ resolve({ status: xhr.status, response });
107
+ } catch {
108
+ resolve({ status: xhr.status, response: xhr.responseText });
109
+ }
110
+ } else {
111
+ reject(new Error(`Upload failed with status ${xhr.status}: ${xhr.statusText}`));
112
+ }
113
+ };
114
+
115
+ xhr.onerror = () => reject(new Error('Network error during upload'));
116
+ xhr.ontimeout = () => reject(new Error(`Upload timeout after ${timeout}ms`));
117
+ xhr.onabort = () => reject(new Error('Upload aborted'));
118
+
119
+ xhr.send(data);
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Safe JSON fetch - fetches and parses JSON with error handling
125
+ * @param {string} url - The URL to fetch
126
+ * @param {object} options - Fetch options
127
+ * @param {number} timeoutMs - Timeout in milliseconds
128
+ * @returns {Promise<{ok: boolean, data: any, error: string|null}>}
129
+ */
130
+ export async function safeJsonFetch(url, options = {}, timeoutMs = 30000) {
131
+ try {
132
+ const response = await fetchWithTimeout(url, options, timeoutMs);
133
+
134
+ if (!response.ok) {
135
+ return {
136
+ ok: false,
137
+ data: null,
138
+ error: `HTTP ${response.status}: ${response.statusText}`
139
+ };
140
+ }
141
+
142
+ const data = await response.json();
143
+ return { ok: true, data, error: null };
144
+ } catch (error) {
145
+ return {
146
+ ok: false,
147
+ data: null,
148
+ error: error.message || 'Unknown network error'
149
+ };
150
+ }
151
+ }
152
+
153
+ // Default timeout constants
154
+ export const TIMEOUT = {
155
+ SHORT: 10000, // 10 seconds - for metadata/small requests
156
+ MEDIUM: 30000, // 30 seconds - default
157
+ LONG: 60000, // 60 seconds - for large operations
158
+ UPLOAD: 120000 // 2 minutes - for file uploads
159
+ };