linguabot commited on
Commit
9a0dd41
·
verified ·
1 Parent(s): 1c628d6

Upload folder using huggingface_hub

Browse files
client/src/components/Refinity.tsx CHANGED
@@ -40,6 +40,7 @@ type Annotation = {
40
  end: number; // exclusive
41
  category: AnnotationCategory;
42
  comment?: string;
 
43
  createdAt: number;
44
  updatedAt: number;
45
  };
@@ -102,6 +103,50 @@ const mockTasks: Task[] = [
102
  ];
103
 
104
  const Refinity: React.FC = () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  const [stage, setStage] = React.useState<Stage>(() => {
106
  try {
107
  const h = String(window.location.hash || '');
@@ -115,6 +160,16 @@ const Refinity: React.FC = () => {
115
  const [tasks, setTasks] = React.useState<Task[]>([]);
116
  const [selectedTaskId, setSelectedTaskId] = React.useState<string>(() => {
117
  try {
 
 
 
 
 
 
 
 
 
 
118
  const h = String(window.location.hash || '');
119
  const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : '';
120
  const params = new URLSearchParams(q);
@@ -254,9 +309,25 @@ const Refinity: React.FC = () => {
254
  const task = React.useMemo(() => tasks.find(t => t.id === selectedTaskId) || tasks[0], [tasks, selectedTaskId]);
255
  const taskVersions = React.useMemo(() => versions.filter(v => v.taskId === (task?.id || '')), [versions, task?.id]);
256
  // Load tasks
257
- // Simple session cache keys
258
- const TASKS_CACHE_KEY = 'refinity_tasks_cache_v1';
259
- const versionsCacheKey = React.useCallback((taskId: string) => `refinity_versions_cache_${taskId}_v1`, []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
  React.useEffect(() => {
262
  (async () => {
@@ -276,18 +347,62 @@ const Refinity: React.FC = () => {
276
  setTasks(cached);
277
  if (!selectedTaskId) {
278
  const initTaskId = initialRouteRef.current?.taskId;
 
279
  if (initTaskId && cached.some((t:any)=>t.id===initTaskId)) {
280
  setSelectedTaskId(initTaskId);
281
- } else {
282
- setSelectedTaskId(cached[0].id);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  }
284
  }
285
  }
286
  } catch {}
287
- const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
288
- const resp = await fetch(`${base}/api/refinity/tasks`);
 
 
 
 
 
 
 
289
  const data: any[] = await resp.json().catch(()=>[]);
290
- const normalized: Task[] = Array.isArray(data) ? data.map(d => ({ id: d._id, title: d.title, sourceText: d.sourceText, createdBy: d.createdBy })) : [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  setTasks(normalized);
292
  try { sessionStorage.setItem(TASKS_CACHE_KEY, JSON.stringify(normalized)); } catch {}
293
  // Apply initial route task selection if available
@@ -295,13 +410,41 @@ const Refinity: React.FC = () => {
295
  if (normalized.length) {
296
  if (initTaskId && normalized.some(t => t.id === initTaskId)) {
297
  setSelectedTaskId(initTaskId);
 
 
 
 
 
 
298
  } else if (!selectedTaskId) {
299
- setSelectedTaskId(normalized[0].id);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  }
301
  }
302
  } catch {}
303
  })();
304
- }, []);
305
 
306
  // Load versions when task changes
307
  React.useEffect(() => {
@@ -311,18 +454,37 @@ const Refinity: React.FC = () => {
311
  // Hydrate versions from cache for instant UI
312
  try {
313
  const cached = JSON.parse(sessionStorage.getItem(versionsCacheKey(task.id)) || '[]');
314
- if (Array.isArray(cached) && cached.length) {
315
- setVersions(cached);
 
 
 
 
 
 
316
  // If versionId missing but stage requests editor, default to latest cached
317
  if (stage === 'editor' && !currentVersionId) {
318
- setCurrentVersionId(cached[cached.length - 1]?.id || null);
319
  }
320
  }
321
  } catch {}
322
- const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
323
- const resp = await fetch(`${base}/api/refinity/tasks/${encodeURIComponent(task.id)}/versions`);
 
 
 
 
 
 
 
 
324
  const data: any[] = await resp.json().catch(()=>[]);
325
- const normalized: Version[] = Array.isArray(data) ? data.map(d => ({ id: d._id, taskId: d.taskId, originalAuthor: d.originalAuthor, revisedBy: d.revisedBy, versionNumber: d.versionNumber, content: d.content, parentVersionId: d.parentVersionId })) : [];
 
 
 
 
 
326
  setVersions(normalized);
327
  try { sessionStorage.setItem(versionsCacheKey(task.id), JSON.stringify(normalized)); } catch {}
328
  // Restore stage/version from initial route if present
@@ -358,14 +520,13 @@ const Refinity: React.FC = () => {
358
  }
359
  } catch { setVersions([]); }
360
  })();
361
- }, [task?.id]);
362
 
363
  const deleteVersion = React.useCallback(async (versionId: string) => {
364
  try {
365
  const ok = window.confirm('Delete this version? This cannot be undone.');
366
  if (!ok) return;
367
- const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
368
- const resp = await fetch(`${base}/api/refinity/versions/${encodeURIComponent(versionId)}`, {
369
  method: 'DELETE',
370
  headers: { 'x-user-name': username, ...(isAdmin ? { 'x-user-role': 'admin' } : {}) }
371
  });
@@ -375,8 +536,7 @@ const Refinity: React.FC = () => {
375
  }, [isAdmin, username]);
376
 
377
  const saveEditedVersion = React.useCallback(async (versionId: string, newContent: string) => {
378
- const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
379
- const resp = await fetch(`${base}/api/refinity/versions/${encodeURIComponent(versionId)}`, {
380
  method: 'PUT',
381
  headers: { 'Content-Type': 'application/json', 'x-user-name': username },
382
  body: JSON.stringify({ content: newContent })
@@ -400,10 +560,21 @@ const Refinity: React.FC = () => {
400
  if (!selectedTaskId) return;
401
  setSavingTaskEdit(true);
402
  try {
403
- const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
404
- const resp = await fetch(`${base}/api/refinity/tasks/${encodeURIComponent(selectedTaskId)}`, {
 
 
 
 
 
 
 
 
 
 
 
405
  method: 'PUT',
406
- headers: { 'Content-Type': 'application/json', 'x-user-name': username },
407
  body: JSON.stringify({ title: editTaskTitle, sourceText: editTaskSource })
408
  });
409
  const updated = await resp.json().catch(()=>({}));
@@ -415,7 +586,7 @@ const Refinity: React.FC = () => {
415
  } finally {
416
  setSavingTaskEdit(false);
417
  }
418
- }, [selectedTaskId, editTaskTitle, editTaskSource, username]);
419
 
420
  const [flowIndex, setFlowIndex] = React.useState<number>(0);
421
  // Keep URL hash in sync with primary navigation state
@@ -634,7 +805,8 @@ const Refinity: React.FC = () => {
634
  const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
635
  const form = new FormData();
636
  form.append('file', file);
637
- const resp = await fetch(`${base}/api/refinity/parse`, { method: 'POST', body: form });
 
638
  const data = await resp.json().catch(()=>({}));
639
  if (!resp.ok) throw new Error(data?.error || 'Failed to parse document');
640
  setNewTaskSource(String(data?.text || ''));
@@ -648,9 +820,26 @@ const Refinity: React.FC = () => {
648
  const src = newTaskSource.trim();
649
  if (!title || !src) return;
650
  try {
651
- const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
652
  const body = { title, sourceText: src, createdBy: username };
653
- const resp = await fetch(`${base}/api/refinity/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
654
  const saved = await resp.json().catch(()=>({}));
655
  if (!resp.ok) throw new Error(saved?.error || 'Save failed');
656
  const t: Task = { id: saved._id, title: saved.title, sourceText: saved.sourceText, createdBy: saved.createdBy };
@@ -693,19 +882,41 @@ const Refinity: React.FC = () => {
693
 
694
  const submitPastedTranslation = async () => {
695
  const text = (pastedTranslation || '').trim();
696
- if (!text) return;
697
  try {
698
- const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
699
- const resp = await fetch(`${base}/api/refinity/tasks/${encodeURIComponent(task?.id || '')}/versions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ originalAuthor: username, revisedBy: undefined, content: text }) });
 
 
 
700
  const saved = await resp.json().catch(()=>({}));
701
- if (!resp.ok) throw new Error(saved?.error || 'Failed to save version');
702
- const newVersion: Version = { id: saved._id, taskId: saved.taskId, originalAuthor: saved.originalAuthor, revisedBy: saved.revisedBy, versionNumber: saved.versionNumber, content: saved.content, parentVersionId: saved.parentVersionId };
 
 
 
 
 
 
 
 
 
 
 
703
  setVersions(prev => [...prev, newVersion]);
704
- setCurrentVersionId(newVersion.id);
 
 
 
 
 
 
705
  setPastedTranslation('');
706
- setCurrentVersionId(null);
707
  setStage('flow');
708
- } catch {}
 
 
709
  };
710
 
711
  const selectManual = (id: string) => {
@@ -718,10 +929,9 @@ const Refinity: React.FC = () => {
718
  if (!task?.id) return;
719
  const ok = window.confirm('Delete this Deep Revision task and all its versions? This cannot be undone.');
720
  if (!ok) return;
721
- const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
722
  const headers: any = { 'x-user-name': username };
723
  if (isAdmin) headers['x-user-role'] = 'admin';
724
- const resp = await fetch(`${base}/api/refinity/tasks/${encodeURIComponent(task.id)}`, { method: 'DELETE', headers });
725
  if (!resp.ok) throw new Error('Delete failed');
726
  setTasks(prev => prev.filter(t => t.id !== task.id));
727
  setVersions([]);
@@ -734,11 +944,11 @@ const Refinity: React.FC = () => {
734
  } catch {}
735
  }, [isAdmin, task?.id, tasks, username]);
736
 
737
- const handleSaveRevision = async (newContent: string) => {
738
  const parent = taskVersions.find(v => v.id === currentVersionId);
 
739
  try {
740
- const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
741
- const resp = await fetch(`${base}/api/refinity/tasks/${encodeURIComponent(task?.id || '')}/versions`, {
742
  method: 'POST',
743
  headers: { 'Content-Type': 'application/json' },
744
  body: JSON.stringify({
@@ -749,7 +959,10 @@ const Refinity: React.FC = () => {
749
  })
750
  });
751
  const saved = await resp.json().catch(()=>({}));
752
- if (!resp.ok) throw new Error(saved?.error || 'Save failed');
 
 
 
753
  const v: Version = {
754
  id: saved._id,
755
  taskId: saved.taskId,
@@ -760,13 +973,21 @@ const Refinity: React.FC = () => {
760
  parentVersionId: saved.parentVersionId,
761
  };
762
  setVersions(prev => [...prev, v]);
 
 
 
 
 
 
 
763
  setCurrentVersionId(v.id);
764
  // Navigate to flow to view the new version without clearing selection
765
  setStage('flow');
766
- } catch {
 
767
  // no-op; keep user in editor if needed
768
  }
769
- };
770
 
771
  return (
772
  <div className={isFullscreen ? 'fixed inset-0 z-50 bg-white overflow-auto' : 'relative'}>
@@ -927,9 +1148,11 @@ const Refinity: React.FC = () => {
927
  <div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
928
  <span className="relative z-10">Start</span>
929
  </button>
 
930
  <button onClick={()=>setShowAddTask(v=>!v)} className="relative inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-violet-700 ring-1 ring-inset ring-violet-300 bg-white/20 backdrop-blur-md hover:bg-violet-50 active:translate-y-0.5 transition-all duration-200">
931
  <span className="relative z-10">New Task</span>
932
  </button>
 
933
  {(isAdmin || String(username).toLowerCase()===String(tasks.find(t=>t.id===selectedTaskId)?.createdBy||'').toLowerCase()) && task?.id && (
934
  <div className="flex items-center gap-2">
935
  <button onClick={openEditTask} className="relative inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-gray-800 ring-1 ring-inset ring-gray-300 bg-white/20 backdrop-blur-md hover:bg-gray-100 active:translate-y-0.5 transition-all duration-200">
@@ -1281,7 +1504,7 @@ const Refinity: React.FC = () => {
1281
  <div className="relative">
1282
  <button ref={compareBtnRef} onClick={(e)=>{ e.preventDefault(); const r=(e.currentTarget as HTMLElement).getBoundingClientRect(); setCompareMenuPos({ left: r.left, top: r.bottom + 8 }); setCompareDownloadOpen(v=>!v); }} disabled={!compareA || !compareB || compareA===compareB} className="px-3 py-2 text-sm rounded-md border border-gray-300 bg-white">Download ▾</button>
1283
  {compareDownloadOpen && compareMenuPos && createPortal(
1284
- <div style={{ position: 'fixed', left: compareMenuPos.left, top: compareMenuPos.top, zIndex: 10000 }} className="w-44 rounded-md border border-gray-200 bg-white shadow-lg text-left">
1285
  <button onClick={async()=>{
1286
  setCompareDownloadOpen(false);
1287
  const a = taskVersions.find(v=>v.id===compareA);
@@ -1335,6 +1558,45 @@ const Refinity: React.FC = () => {
1335
  link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);
1336
  } catch {}
1337
  }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Tracked Changes</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1338
  </div>, document.body
1339
  )}
1340
  </div>
@@ -1379,7 +1641,7 @@ const Refinity: React.FC = () => {
1379
  ` }} />
1380
  </div>
1381
  {compareModalOpen && (
1382
- <div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" role="dialog" aria-modal="true">
1383
  <div className="relative max-w-5xl w-full max-h-[85vh] rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200 p-0 overflow-hidden">
1384
  <div className="flex items-center justify-between px-6 pt-5 pb-3 border-b sticky top-0 z-10 bg-white">
1385
  <div className="text-gray-800 font-medium">Diff</div>
@@ -1424,6 +1686,44 @@ const Refinity: React.FC = () => {
1424
  link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);
1425
  } catch {}
1426
  }} className="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white">Export (Tracked Changes)</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1427
  <button onClick={()=>setCompareModalOpen(false)} className="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white">Close</button>
1428
  </div>
1429
  </div>
@@ -1463,6 +1763,7 @@ const Refinity: React.FC = () => {
1463
  <EditorPane
1464
  source={task?.sourceText || ''}
1465
  initialTranslation={currentVersion?.content || ''}
 
1466
  onBack={()=>setStage('flow')}
1467
  onSave={handleSaveRevision}
1468
  onSaveEdit={editingVersionId ? ((text)=>saveEditedVersion(editingVersionId, text)) : undefined}
@@ -1481,7 +1782,7 @@ const Refinity: React.FC = () => {
1481
  );
1482
  };
1483
 
1484
- const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack: ()=>void; onSave: (text: string)=>void; onSaveEdit?: (text: string)=>void; taskTitle: string; username: string; nextVersionNumber: number; isFullscreen: boolean; onToggleFullscreen: ()=>void; versionId: string }>=({ source, initialTranslation, onBack, onSave, onSaveEdit, taskTitle, username, nextVersionNumber, isFullscreen, onToggleFullscreen, versionId })=>{
1485
  const [text, setText] = React.useState<string>(initialTranslation);
1486
  const [saving, setSaving] = React.useState(false);
1487
  // Sync text with incoming props when version/context changes (e.g., refresh -> data loads)
@@ -1492,7 +1793,10 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1492
  const [showDiff, setShowDiff] = React.useState<boolean>(false);
1493
  const [revDownloadOpen, setRevDownloadOpen] = React.useState<boolean>(false);
1494
  const [revMenuPos, setRevMenuPos] = React.useState<{ left: number; top: number } | null>(null);
 
 
1495
  const revBtnRef = React.useRef<HTMLButtonElement | null>(null);
 
1496
  const sourceRef = React.useRef<HTMLDivElement>(null);
1497
  const [textareaHeight, setTextareaHeight] = React.useState('420px');
1498
  const [commentsOpen, setCommentsOpen] = React.useState(false);
@@ -1501,16 +1805,9 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1501
  const taRef = React.useRef<HTMLTextAreaElement | null>(null);
1502
  const overlayRef = React.useRef<HTMLDivElement | null>(null);
1503
  const wrapperRef = React.useRef<HTMLDivElement | null>(null);
 
1504
  // Annotation layer state (revision mode only)
1505
  const [showAnnotations, setShowAnnotations] = React.useState<boolean>(true);
1506
- // Comment-first workflow state (per-version; persisted in sessionStorage)
1507
- const [commentsSaved, setCommentsSaved] = React.useState<boolean>(false);
1508
- React.useEffect(() => {
1509
- try {
1510
- const v = sessionStorage.getItem(`refinity_comments_saved_${versionId}`) === '1';
1511
- setCommentsSaved(!!v);
1512
- } catch { setCommentsSaved(false); }
1513
- }, [versionId]);
1514
  const [toastMsg, setToastMsg] = React.useState<string>('');
1515
  const [dirty, setDirty] = React.useState<boolean>(false);
1516
  React.useEffect(() => { setDirty(false); }, [versionId, initialTranslation]);
@@ -1522,35 +1819,6 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1522
  return () => window.removeEventListener('beforeunload', handler);
1523
  }, [dirty]);
1524
  const showToast = (msg: string) => { setToastMsg(msg); setTimeout(()=>setToastMsg(''), 1800); };
1525
- const statusRef = React.useRef<HTMLDivElement | null>(null);
1526
- const [statusH, setStatusH] = React.useState<number>(0);
1527
- React.useLayoutEffect(() => {
1528
- const el = statusRef.current;
1529
- if (!el) { setStatusH(0); return; }
1530
- const compute = () => {
1531
- const h = el.offsetHeight || 0;
1532
- let mt = 0, mb = 0;
1533
- try {
1534
- const cs = window.getComputedStyle(el);
1535
- mt = parseFloat(cs.marginTop || '0') || 0;
1536
- mb = parseFloat(cs.marginBottom || '0') || 0;
1537
- } catch {}
1538
- setStatusH(h + mt + mb);
1539
- };
1540
- compute();
1541
- // Track dynamic changes without layout flicker
1542
- let ro: ResizeObserver | null = null;
1543
- try {
1544
- ro = new ResizeObserver(() => compute());
1545
- ro.observe(el);
1546
- } catch {}
1547
- const onResize = () => compute();
1548
- window.addEventListener('resize', onResize);
1549
- return () => {
1550
- window.removeEventListener('resize', onResize);
1551
- if (ro) try { ro.disconnect(); } catch {}
1552
- };
1553
- }, [commentsSaved]);
1554
  const ANNO_STORE_KEY = 'refinity_annotations_v1';
1555
  type Ann = Annotation;
1556
  const loadAnnotations = React.useCallback((): Ann[] => {
@@ -1578,6 +1846,11 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1578
  const [editingAnn, setEditingAnn] = React.useState<Ann | null>(null);
1579
  const [modalCategory, setModalCategory] = React.useState<AnnotationCategory>('distortion');
1580
  const [modalComment, setModalComment] = React.useState('');
 
 
 
 
 
1581
  const [popover, setPopover] = React.useState<{ left: number; top: number; start: number; end: number } | null>(null);
1582
 
1583
  function escapeHtml(s: string): string {
@@ -1588,61 +1861,117 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1588
  (async () => {
1589
  if (!versionId) return;
1590
  try {
1591
- const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
1592
- const resp = await fetch(`${base}/api/refinity/annotations?versionId=${encodeURIComponent(versionId)}`);
1593
  const rows = await resp.json().catch(()=>[]);
1594
  if (Array.isArray(rows)) {
1595
  setAnnotations(prev => {
1596
  const others = prev.filter(a => a.versionId !== versionId);
1597
- const loaded = rows.map((r:any)=>({ id: r._id, versionId: r.versionId, start: r.start, end: r.end, category: r.category, comment: r.comment, createdAt: r.createdAt, updatedAt: r.updatedAt }));
 
 
 
 
 
 
 
 
 
 
 
 
 
1598
  return [...others, ...loaded];
1599
  });
1600
  }
1601
  } catch {}
1602
  })();
1603
- }, [versionId]);
1604
 
1605
  // Persistence helpers
1606
  const persistCreate = React.useCallback(async (a: Ann): Promise<Ann> => {
1607
  try {
1608
- const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
1609
- const body = { versionId: a.versionId, start: a.start, end: a.end, category: a.category, comment: a.comment };
1610
- const resp = await fetch(`${base}/api/refinity/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
1611
  const row = await resp.json().catch(()=>({}));
1612
  if (resp.ok && row && row._id) return { ...a, id: row._id };
1613
  } catch {}
1614
  return a;
1615
- }, []);
1616
  const persistUpdate = React.useCallback(async (a: Ann) => {
1617
  try {
1618
- const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
1619
- const body = { start: a.start, end: a.end, category: a.category, comment: a.comment };
1620
- await fetch(`${base}/api/refinity/annotations/${encodeURIComponent(a.id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
1621
  } catch {}
1622
- }, []);
1623
  const persistDelete = React.useCallback(async (id: string) => {
1624
  try {
1625
- const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
1626
- await fetch(`${base}/api/refinity/annotations/${encodeURIComponent(id)}`, { method: 'DELETE' });
1627
  } catch {}
1628
- }, []);
1629
  function renderAnnotatedHtml(textValue: string): string {
1630
- if (!showAnnotations) return escapeHtml(textValue);
 
 
 
 
 
1631
  const list = versionAnnotations.slice().sort((a,b)=>a.start-b.start);
1632
- if (!list.length) return escapeHtml(textValue);
1633
  let html = ''; let pos = 0;
1634
  for (const a of list) {
1635
  const start = Math.max(0, Math.min(a.start, textValue.length));
1636
  const end = Math.max(start, Math.min(a.end, textValue.length));
1637
- if (pos < start) html += escapeHtml(textValue.slice(pos, start));
 
 
 
 
 
1638
  const cls = CATEGORY_CLASS[a.category] || 'bg-gray-100';
1639
- const span = escapeHtml(textValue.slice(start, end)) || '&nbsp;';
1640
- html += `<span data-anno-id="${a.id}" class="inline rounded-[4px] ${cls}" title="${a.category}${a.comment ? ': '+escapeHtml(a.comment) : ''}">${span}</span>`;
1641
  pos = end;
1642
  }
1643
- if (pos < textValue.length) html += escapeHtml(textValue.slice(pos));
 
 
 
 
 
1644
  return html;
1645
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1646
  // Adjust annotations when text changes so highlights track or are removed
1647
  const adjustAnnotationsForEdit = React.useCallback((oldText: string, newText: string) => {
1648
  if (oldText === newText) return;
@@ -1673,32 +2002,122 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1673
  return [...others, ...adjusted];
1674
  });
1675
  }, [versionId, setAnnotations]);
1676
- function offsetsToClientRect(container: HTMLElement, start: number, end: number): DOMRect | null {
1677
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
1678
- let charCount = 0;
1679
- let startNode: Text | null = null, endNode: Text | null = null;
1680
- let startOffset = 0, endOffset = 0;
1681
- while (walker.nextNode()) {
1682
- const node = walker.currentNode as Text;
1683
- const len = (node.nodeValue || '').length;
1684
- if (!startNode && charCount + len >= start) { startNode = node; startOffset = start - charCount; }
1685
- if (!endNode && charCount + len >= end) { endNode = node; endOffset = end - charCount; break; }
1686
- charCount += len;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1687
  }
1688
- if (!startNode || !endNode) return null;
1689
- const r = document.createRange();
1690
- r.setStart(startNode, Math.max(0, Math.min(startOffset, (startNode.nodeValue||'').length)));
1691
- r.setEnd(endNode, Math.max(0, Math.min(endOffset, (endNode.nodeValue||'').length)));
1692
- const rect = r.getBoundingClientRect();
1693
- return rect;
1694
  }
 
1695
  const updateSelectionPopover = React.useCallback(() => {
1696
- const ta = taRef.current, ov = overlayRef.current, wrap = wrapperRef.current;
1697
- if (!ta || !ov || !wrap) { setPopover(null); return; }
1698
- const s = ta.selectionStart ?? 0, e = ta.selectionEnd ?? 0;
1699
- if (e - s <= 0) { setPopover(null); return; }
1700
- const rect = offsetsToClientRect(ov, s, e);
1701
- if (!rect) { setPopover(null); return; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1702
  const wrect = wrap.getBoundingClientRect();
1703
  const btnW = 120, btnH = 30, padding = 6;
1704
  // Center over the selection; clamp within container
@@ -1709,7 +2128,8 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1709
  if (top < padding) {
1710
  top = rect.bottom - wrect.top + padding;
1711
  }
1712
- setPopover({ left, top, start: s, end: e });
 
1713
  }, []);
1714
 
1715
  React.useEffect(() => {
@@ -1741,6 +2161,54 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1741
  return () => window.removeEventListener('resize', onResize);
1742
  }, []);
1743
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1744
  // Keep overlay aligned with textarea scroll position across renders/toggles
1745
  React.useLayoutEffect(() => {
1746
  const ta = taRef.current;
@@ -1753,9 +2221,12 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1753
  setSaving(true);
1754
  try {
1755
  if (onSaveEdit) {
 
1756
  await onSaveEdit(text);
1757
  } else {
1758
- onSave(text);
 
 
1759
  }
1760
  } finally {
1761
  setSaving(false);
@@ -1765,7 +2236,9 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1765
  const compareNow = async ()=>{
1766
  try {
1767
  const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
1768
- const resp = await fetch(`${base}/api/refinity/diff`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prev: initialTranslation || '', current: text || '' }) });
 
 
1769
  const data = await resp.json().catch(()=>({}));
1770
  if (!resp.ok) throw new Error(data?.error || 'Diff failed');
1771
  // Ensure layout preserved if backend HTML lacks <br/>
@@ -1783,7 +2256,9 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1783
  const userNameSafe = toSafeName(username || 'User');
1784
  const vNum = `v${nextVersionNumber}`;
1785
  const filename = `${taskNameSafe}_${vNum}_Clean_${yyyymmdd_hhmm()}_${userNameSafe}.docx`;
1786
- const resp = await fetch(`${base}/api/refinity/track-changes-ooxml`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prev: initialTranslation || '', current: text || '', filename, includeComments: false }) });
 
 
1787
  if (!resp.ok) throw new Error('Export failed');
1788
  const blob = await resp.blob();
1789
  const url = window.URL.createObjectURL(blob);
@@ -1803,9 +2278,18 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1803
  <div>
1804
  <div className="flex items-start gap-6">
1805
  <div className="w-1/2">
1806
- <div className="mb-2 text-gray-700 text-sm">Source</div>
1807
- {/* Spacer to align with translation status bar (exact measured height) */}
1808
- <div style={{ height: Math.max(0, statusH) }} />
 
 
 
 
 
 
 
 
 
1809
  <div className="relative rounded-lg overflow-hidden">
1810
  <div className="absolute inset-0 rounded-lg" style={{ background: 'radial-gradient(120%_120%_at_0%_0%, #dbeafe 0%, #ffffff 50%, #c7d2fe 100%)' }} />
1811
  <div ref={sourceRef} className="relative rounded-lg bg-white/60 backdrop-blur-md ring-1 ring-inset ring-indigo-300 shadow p-4 min-h-[420px] whitespace-pre-wrap text-gray-900">
@@ -1817,10 +2301,6 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1817
  <div className="mb-2 text-gray-700 text-sm flex items-center justify-between">
1818
  <span>Translation</span>
1819
  <div className="flex items-center gap-2">
1820
- <label className="hidden md:flex items-center gap-2 text-xs text-gray-800 ring-1 ring-gray-200 rounded-xl px-2 py-0.5 bg-white/50">
1821
- <input type="checkbox" checked={showAnnotations} onChange={(e)=>{ setShowAnnotations(e.target.checked); setPopover(null); }} />
1822
- <span>Show comments</span>
1823
- </label>
1824
  {!isFullscreen && (
1825
  <button onClick={onToggleFullscreen} className="inline-flex items-center px-2 py-1 text-xs rounded-xl bg-white/60 ring-1 ring-gray-200 text-gray-800 hover:bg-white">
1826
  Full Screen
@@ -1828,63 +2308,87 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1828
  )}
1829
  </div>
1830
  </div>
1831
- {/* Status bar */}
1832
- <div ref={statusRef} className="mb-2 text-xs">
1833
- {!commentsSaved ? (
1834
- <div className="rounded-md bg-amber-50 text-amber-900 ring-1 ring-amber-200 px-3 py-1">Step 1: Add comments. Save to continue to editing.</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
1835
  ) : (
1836
- <div className="rounded-md bg-emerald-50 text-emerald-900 ring-1 ring-emerald-200 px-3 py-1">Comments saved. Editing unlocked.</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1837
  )}
1838
- </div>
1839
- <div ref={wrapperRef} className="relative overflow-hidden">
1840
- {/* Overlay highlights rendered above, but non-interactive; textarea handles selection */}
1841
- <div
1842
- ref={overlayRef}
1843
- className="pointer-events-none select-none absolute inset-0 rounded-lg px-4 py-3 whitespace-pre-wrap overflow-visible z-0 text-base leading-relaxed will-change-transform font-sans tracking-normal"
1844
- style={{ minHeight: textareaHeight, height: textareaHeight, maxHeight: textareaHeight, opacity: showAnnotations ? 1 : 0, color: 'transparent' }}
1845
- dangerouslySetInnerHTML={{ __html: renderAnnotatedHtml(text) }}
1846
- />
1847
- <textarea
1848
- ref={taRef}
1849
- value={text}
1850
- readOnly={!commentsSaved}
1851
- onChange={async (e)=>{
1852
- const val = e.target.value;
1853
- if (!commentsSaved) return; // locked
1854
- setDirty(true);
1855
- adjustAnnotationsForEdit(text, val);
1856
- setText(val);
1857
- }}
1858
- onMouseUp={()=>{
1859
- updateSelectionPopover();
1860
- // If caret inside an existing annotation, open edit modal
1861
- const s = taRef.current?.selectionStart ?? 0;
1862
- const e = taRef.current?.selectionEnd ?? 0;
1863
- if (s === e) {
1864
- const hit = versionAnnotations.find(a => s >= a.start && s <= a.end);
1865
- if (hit) {
1866
- setEditingAnn(hit);
1867
- setModalCategory(hit.category);
1868
- setModalComment(hit.comment || '');
1869
- setModalOpen(true);
1870
- }
1871
- }
1872
- }}
1873
- onScroll={(e)=>{ const el = e.currentTarget; if (overlayRef.current) { overlayRef.current.style.transform = `translateY(-${el.scrollTop}px)`; } }}
1874
- onKeyUp={updateSelectionPopover}
1875
- className="relative z-20 w-full px-4 py-3 border border-ui-border rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white text-base leading-relaxed font-sans tracking-normal"
1876
- style={{
1877
- minHeight: textareaHeight,
1878
- height: textareaHeight,
1879
- maxHeight: textareaHeight,
1880
- resize: 'none',
1881
- overflowY: 'auto',
1882
- background: 'transparent',
1883
- color: '#111827' as any,
1884
- caretColor: '#111827',
1885
- opacity: !commentsSaved ? 0.85 : 1
1886
- }}
1887
- />
1888
  {/* + Comment popover */}
1889
  {popover && (
1890
  <div className="absolute z-20" style={{ left: popover.left, top: popover.top }}>
@@ -1894,6 +2398,21 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1894
  setEditingAnn(null);
1895
  setModalCategory('distortion');
1896
  setModalComment('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1897
  setModalOpen(true);
1898
  }}
1899
  className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-emerald-700 active:translate-y-0.5 transition-all duration-200"
@@ -1907,21 +2426,13 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1907
  </div>
1908
  <div className="mt-4 flex gap-3 relative">
1909
  <button
1910
- onClick={()=>{
1911
- setCommentsSaved(true);
1912
- try { sessionStorage.setItem(`refinity_comments_saved_${versionId}`,'1'); } catch {}
1913
- showToast('Comments saved — you can now edit the text.');
1914
- }}
1915
- className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-emerald-600/80 hover:bg-emerald-700 active:translate-y-0.5 transition-all duration-200"
1916
  >
1917
  <div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" />
1918
  <div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
1919
- Save Comments
1920
- </button>
1921
- <button title={!commentsSaved ? 'Save comments to enable editing.' : undefined} onClick={() => { setDirty(false); save(); }} disabled={saving || !commentsSaved} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-indigo-600/70 hover:bg-indigo-700 disabled:bg-gray-400 active:translate-y-0.5 transition-all duration-200">
1922
- <div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" />
1923
- <div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
1924
- {saving? 'Saving…':'Save Version'}
1925
  </button>
1926
  <div className="relative inline-block align-top">
1927
  <button
@@ -1942,8 +2453,8 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1942
  </button>
1943
  {revDownloadOpen && revMenuPos && createPortal(
1944
  <div style={{ position: 'fixed', left: revMenuPos.left, top: revMenuPos.top, zIndex: 10000, maxHeight: '240px', overflowY: 'auto' }} className="w-56 rounded-md border border-gray-200 bg-white shadow-lg text-left">
1945
- <button onClick={async()=>{ setRevDownloadOpen(false); try { const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const filename=`${toSafeName(taskTitle||'Task')}_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`; const body={ current: text||'', filename }; let resp=await fetch(`${base}/api/refinity/export-plain`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body) }); if(!resp.ok) throw new Error('Export failed'); const blob=await resp.blob(); await saveBlobToDisk(blob, filename);} catch { await saveTextFallback(text||'', `${toSafeName(taskTitle||'Task')}_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.txt`);} }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Without Annotations</button>
1946
- <button onClick={async()=>{ setRevDownloadOpen(false); try { const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const filename=`${toSafeName(taskTitle||'Task')}_v${nextVersionNumber}_Annotated_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`; const list=versionAnnotations.map(a=>({ start:a.start, end:a.end, category:a.category, comment:a.comment })); const body={ current: text||'', filename, annotations: list }; let resp=await fetch(`${base}/api/refinity/export-plain-with-annotations`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body) }); if(!resp.ok){ resp=await fetch(`${base}/api/refinity/export-plain`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ current: text||'', filename }) }); } if(!resp.ok) throw new Error('Export failed'); const blob=await resp.blob(); await saveBlobToDisk(blob, filename);} catch { await saveTextFallback(text||'', `${toSafeName(taskTitle||'Task')}_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.txt`);} }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">With Annotations</button>
1947
  </div>, document.body
1948
  )}
1949
  </div>
@@ -1952,12 +2463,106 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1952
  </div>
1953
  {/* Inline comments drawer removed */}
1954
  {showDiff && (
1955
- <div className="mt-6 relative rounded-xl">
1956
- <div className="absolute inset-0 rounded-xl bg-gradient-to-r from-indigo-200/45 via-indigo-100/40 to-indigo-300/45" />
1957
- <div className="relative rounded-xl bg-white/10 backdrop-blur-md ring-1 ring-inset ring-white/30 shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)] p-4">
1958
- <div className="pointer-events-none absolute inset-0 rounded-xl opacity-50 [background:linear-gradient(to_bottom,rgba(255,255,255,0.3),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.28),rgba(255,255,255,0)_28%)]" />
1959
- <div className="pointer-events-none absolute inset-0 rounded-xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-45" />
1960
- <div className="relative text-gray-900 prose prose-sm max-w-none whitespace-pre-wrap" dangerouslySetInnerHTML={{ __html: diffHtml }} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1961
  </div>
1962
  </div>
1963
  )}
@@ -1969,50 +2574,155 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1969
  {/* Annotation modal */}
1970
  {modalOpen && (
1971
  <div className="fixed inset-0 z-[5000] flex items-center justify-center bg-black/40">
1972
- <div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
1973
- <h3 className="text-lg font-semibold text-gray-900 mb-4">{editingAnn ? 'Edit annotation' : 'New annotation'}</h3>
1974
- <div className="space-y-4">
1975
- <div>
1976
- <label className="block text-sm text-gray-700 mb-1">Category <span className="text-red-500">*</span></label>
1977
- <select
1978
- value={modalCategory}
1979
- onChange={(e)=>setModalCategory(e.target.value as AnnotationCategory)}
1980
- className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm"
 
 
 
 
1981
  >
1982
- <option value="distortion">Distortion</option>
1983
- <option value="omission">Unjustified omission</option>
1984
- <option value="register">Inappropriate register</option>
1985
- <option value="unidiomatic">Unidiomatic expression</option>
1986
- <option value="grammar">Error of grammar, syntax</option>
1987
- <option value="spelling">Error of spelling</option>
1988
- <option value="punctuation">Error of punctuation</option>
1989
- <option value="addition">Unjustified addition</option>
1990
- <option value="other">Other</option>
1991
- </select>
1992
- </div>
1993
- <div>
1994
- <label className="block text-sm text-gray-700 mb-1">Comment (optional)</label>
1995
- <textarea value={modalComment} onChange={(e)=>setModalComment(e.target.value)} rows={3} className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm" placeholder="Enter the correction or context…" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1996
  </div>
1997
  </div>
1998
  <div className="mt-6 flex items-center justify-between">
1999
  {editingAnn && (
2000
- <button onClick={async()=>{ await persistDelete(editingAnn.id); deleteAnnotationById(editingAnn.id); setModalOpen(false); setPopover(null); setEditingAnn(null); }} className="px-3 py-1.5 text-sm rounded-lg text-white bg-red-600 hover:bg-red-700">Delete</button>
2001
  )}
2002
  <div className="ml-auto flex gap-2">
2003
- <button onClick={()=>{ setModalOpen(false); setPopover(null); setEditingAnn(null); }} className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 text-gray-700 bg-white hover:bg-gray-50">Cancel</button>
2004
  <button
2005
  onClick={async ()=>{
 
 
 
 
 
2006
  if (editingAnn) {
2007
- const updated = { ...editingAnn, category: modalCategory, comment: modalComment, updatedAt: Date.now() };
 
 
 
 
 
 
2008
  updateAnnotation(updated);
2009
  await persistUpdate(updated);
2010
  } else if (popover) {
2011
- const local = { id:`a_${Date.now()}_${Math.random().toString(36).slice(2,7)}`, versionId, start: popover.start, end: popover.end, category: modalCategory, comment: modalComment, createdAt: Date.now(), updatedAt: Date.now() };
 
 
 
 
 
 
 
 
 
 
2012
  const saved = await persistCreate(local);
2013
  addAnnotation(saved);
2014
  }
2015
- setModalOpen(false); setPopover(null); setEditingAnn(null);
2016
  }}
2017
  className="px-3 py-1.5 text-sm rounded-lg text-white bg-indigo-600 hover:bg-indigo-700"
2018
  >
@@ -2020,7 +2730,7 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
2020
  </button>
2021
  </div>
2022
  </div>
2023
- </div>
2024
  </div>
2025
  )}
2026
  </div>
 
40
  end: number; // exclusive
41
  category: AnnotationCategory;
42
  comment?: string;
43
+ correction?: string;
44
  createdAt: number;
45
  updatedAt: number;
46
  };
 
103
  ];
104
 
105
  const Refinity: React.FC = () => {
106
+ // Check if we're in tutorial mode - use state to make it reactive
107
+ const [isTutorialMode, setIsTutorialMode] = React.useState<boolean>(() => {
108
+ try {
109
+ return localStorage.getItem('refinityMode') === 'tutorial';
110
+ } catch {
111
+ return false;
112
+ }
113
+ });
114
+
115
+ // Listen for changes to tutorial mode
116
+ React.useEffect(() => {
117
+ const checkTutorialMode = () => {
118
+ try {
119
+ setIsTutorialMode(localStorage.getItem('refinityMode') === 'tutorial');
120
+ } catch {
121
+ setIsTutorialMode(false);
122
+ }
123
+ };
124
+ // Check immediately
125
+ checkTutorialMode();
126
+ // Listen for storage events (when TutorialRefinity sets it)
127
+ window.addEventListener('storage', checkTutorialMode);
128
+ // Also poll periodically to catch changes from same window
129
+ const interval = setInterval(checkTutorialMode, 100);
130
+ return () => {
131
+ window.removeEventListener('storage', checkTutorialMode);
132
+ clearInterval(interval);
133
+ };
134
+ }, []);
135
+
136
+ const getApiBase = React.useCallback((endpoint: string) => {
137
+ // Use localhost when running locally, otherwise use api base
138
+ const isLocalhost = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname);
139
+ const base = isLocalhost ? 'http://localhost:5000' : (((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''));
140
+ // Check localStorage directly to ensure we always have the latest value
141
+ try {
142
+ const mode = localStorage.getItem('refinityMode');
143
+ if (mode === 'tutorial') {
144
+ return `${base}/api/tutorial-refinity${endpoint}`;
145
+ }
146
+ } catch {}
147
+ return `${base}/api/refinity${endpoint}`;
148
+ }, []);
149
+
150
  const [stage, setStage] = React.useState<Stage>(() => {
151
  try {
152
  const h = String(window.location.hash || '');
 
160
  const [tasks, setTasks] = React.useState<Task[]>([]);
161
  const [selectedTaskId, setSelectedTaskId] = React.useState<string>(() => {
162
  try {
163
+ // Check if in tutorial mode and restore from localStorage
164
+ const isTutorial = localStorage.getItem('refinityMode') === 'tutorial';
165
+ if (isTutorial) {
166
+ const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0');
167
+ if (weekNumber > 0) {
168
+ const saved = localStorage.getItem(`tutorial_selected_task_week_${weekNumber}`);
169
+ if (saved) return saved;
170
+ }
171
+ }
172
+ // Otherwise, try URL hash
173
  const h = String(window.location.hash || '');
174
  const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : '';
175
  const params = new URLSearchParams(q);
 
309
  const task = React.useMemo(() => tasks.find(t => t.id === selectedTaskId) || tasks[0], [tasks, selectedTaskId]);
310
  const taskVersions = React.useMemo(() => versions.filter(v => v.taskId === (task?.id || '')), [versions, task?.id]);
311
  // Load tasks
312
+ // Simple session cache keys - use different keys for tutorial mode to avoid conflicts
313
+ // Check localStorage directly for tutorial mode
314
+ const getTutorialMode = React.useCallback(() => {
315
+ try {
316
+ return localStorage.getItem('refinityMode') === 'tutorial';
317
+ } catch {
318
+ return false;
319
+ }
320
+ }, []);
321
+
322
+ const TASKS_CACHE_KEY = React.useMemo(() => {
323
+ const isTut = getTutorialMode();
324
+ return isTut ? 'tutorial_refinity_tasks_cache_v1' : 'refinity_tasks_cache_v1';
325
+ }, [getTutorialMode]);
326
+
327
+ const versionsCacheKey = React.useCallback((taskId: string) => {
328
+ const isTut = getTutorialMode();
329
+ return isTut ? `tutorial_refinity_versions_cache_${taskId}_v1` : `refinity_versions_cache_${taskId}_v1`;
330
+ }, [getTutorialMode]);
331
 
332
  React.useEffect(() => {
333
  (async () => {
 
347
  setTasks(cached);
348
  if (!selectedTaskId) {
349
  const initTaskId = initialRouteRef.current?.taskId;
350
+ const isTutorial = getTutorialMode();
351
  if (initTaskId && cached.some((t:any)=>t.id===initTaskId)) {
352
  setSelectedTaskId(initTaskId);
353
+ if (isTutorial) {
354
+ const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0');
355
+ if (weekNumber > 0) {
356
+ localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, initTaskId);
357
+ }
358
+ }
359
+ } else if (cached.length) {
360
+ if (isTutorial) {
361
+ const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0');
362
+ if (weekNumber > 0) {
363
+ const savedTaskId = localStorage.getItem(`tutorial_selected_task_week_${weekNumber}`);
364
+ if (savedTaskId && cached.some((t:any)=>t.id===savedTaskId)) {
365
+ setSelectedTaskId(savedTaskId);
366
+ } else {
367
+ setSelectedTaskId(cached[0].id);
368
+ localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, cached[0].id);
369
+ }
370
+ } else {
371
+ setSelectedTaskId(cached[0].id);
372
+ }
373
+ } else {
374
+ setSelectedTaskId(cached[0].id);
375
+ }
376
  }
377
  }
378
  }
379
  } catch {}
380
+ // In tutorial mode, include weekNumber in query
381
+ let tasksUrl = getApiBase('/tasks');
382
+ if (getTutorialMode()) {
383
+ const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0');
384
+ if (weekNumber > 0) {
385
+ tasksUrl += `?weekNumber=${weekNumber}`;
386
+ }
387
+ }
388
+ const resp = await fetch(tasksUrl);
389
  const data: any[] = await resp.json().catch(()=>[]);
390
+ // In tutorial mode, only show tasks created by admin
391
+ let normalized: Task[] = Array.isArray(data) ? data.map(d => ({ id: d._id, title: d.title, sourceText: d.sourceText, createdBy: d.createdBy })) : [];
392
+ const currentTutorialMode = getTutorialMode();
393
+ if (currentTutorialMode) {
394
+ // Filter to only show admin-created tasks
395
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
396
+ const isAdmin = user.role === 'admin';
397
+ if (!isAdmin) {
398
+ // Non-admin users: only show tasks created by admin
399
+ normalized = normalized.filter(t => {
400
+ // Check if task was created by admin (createdBy should be admin user ID or email)
401
+ // For now, we'll show all tasks and let backend filter
402
+ return true;
403
+ });
404
+ }
405
+ }
406
  setTasks(normalized);
407
  try { sessionStorage.setItem(TASKS_CACHE_KEY, JSON.stringify(normalized)); } catch {}
408
  // Apply initial route task selection if available
 
410
  if (normalized.length) {
411
  if (initTaskId && normalized.some(t => t.id === initTaskId)) {
412
  setSelectedTaskId(initTaskId);
413
+ if (currentTutorialMode) {
414
+ const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0');
415
+ if (weekNumber > 0) {
416
+ localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, initTaskId);
417
+ }
418
+ }
419
  } else if (!selectedTaskId) {
420
+ // In tutorial mode, try to restore from localStorage
421
+ if (currentTutorialMode) {
422
+ const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0');
423
+ if (weekNumber > 0) {
424
+ const savedTaskId = localStorage.getItem(`tutorial_selected_task_week_${weekNumber}`);
425
+ if (savedTaskId && normalized.some(t => t.id === savedTaskId)) {
426
+ setSelectedTaskId(savedTaskId);
427
+ } else {
428
+ setSelectedTaskId(normalized[0].id);
429
+ localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, normalized[0].id);
430
+ }
431
+ } else {
432
+ setSelectedTaskId(normalized[0].id);
433
+ }
434
+ } else {
435
+ setSelectedTaskId(normalized[0].id);
436
+ }
437
+ } else if (currentTutorialMode && selectedTaskId && normalized.some(t => t.id === selectedTaskId)) {
438
+ // Persist selected task in tutorial mode
439
+ const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0');
440
+ if (weekNumber > 0) {
441
+ localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, selectedTaskId);
442
+ }
443
  }
444
  }
445
  } catch {}
446
  })();
447
+ }, [getApiBase, TASKS_CACHE_KEY, selectedTaskId, tasks.length, getTutorialMode]);
448
 
449
  // Load versions when task changes
450
  React.useEffect(() => {
 
454
  // Hydrate versions from cache for instant UI
455
  try {
456
  const cached = JSON.parse(sessionStorage.getItem(versionsCacheKey(task.id)) || '[]');
457
+ // In tutorial mode, filter to only show current user's versions (unless admin)
458
+ let filteredCache = cached;
459
+ const currentTutorialMode = getTutorialMode();
460
+ if (currentTutorialMode && !isAdmin) {
461
+ filteredCache = cached.filter((v: any) => v.originalAuthor === username || v.revisedBy === username);
462
+ }
463
+ if (Array.isArray(filteredCache) && filteredCache.length) {
464
+ setVersions(filteredCache);
465
  // If versionId missing but stage requests editor, default to latest cached
466
  if (stage === 'editor' && !currentVersionId) {
467
+ setCurrentVersionId(filteredCache[filteredCache.length - 1]?.id || null);
468
  }
469
  }
470
  } catch {}
471
+ // Send user identification headers for backend filtering
472
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
473
+ const headers: any = {};
474
+ if (user.name) headers['x-user-name'] = user.name;
475
+ if (user.email) headers['x-user-email'] = user.email;
476
+ if (user.role === 'admin') {
477
+ headers['x-user-role'] = 'admin';
478
+ headers['user-role'] = 'admin';
479
+ }
480
+ const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(task.id)}/versions`), { headers });
481
  const data: any[] = await resp.json().catch(()=>[]);
482
+ let normalized: Version[] = Array.isArray(data) ? data.map(d => ({ id: d._id, taskId: d.taskId, originalAuthor: d.originalAuthor, revisedBy: d.revisedBy, versionNumber: d.versionNumber, content: d.content, parentVersionId: d.parentVersionId })) : [];
483
+ // In tutorial mode, filter to only show current user's versions (unless admin)
484
+ const currentTutorialMode = getTutorialMode();
485
+ if (currentTutorialMode && !isAdmin) {
486
+ normalized = normalized.filter(v => v.originalAuthor === username || v.revisedBy === username);
487
+ }
488
  setVersions(normalized);
489
  try { sessionStorage.setItem(versionsCacheKey(task.id), JSON.stringify(normalized)); } catch {}
490
  // Restore stage/version from initial route if present
 
520
  }
521
  } catch { setVersions([]); }
522
  })();
523
+ }, [task?.id, getApiBase, getTutorialMode, versionsCacheKey, isAdmin]);
524
 
525
  const deleteVersion = React.useCallback(async (versionId: string) => {
526
  try {
527
  const ok = window.confirm('Delete this version? This cannot be undone.');
528
  if (!ok) return;
529
+ const resp = await fetch(getApiBase(`/versions/${encodeURIComponent(versionId)}`), {
 
530
  method: 'DELETE',
531
  headers: { 'x-user-name': username, ...(isAdmin ? { 'x-user-role': 'admin' } : {}) }
532
  });
 
536
  }, [isAdmin, username]);
537
 
538
  const saveEditedVersion = React.useCallback(async (versionId: string, newContent: string) => {
539
+ const resp = await fetch(getApiBase(`/versions/${encodeURIComponent(versionId)}`), {
 
540
  method: 'PUT',
541
  headers: { 'Content-Type': 'application/json', 'x-user-name': username },
542
  body: JSON.stringify({ content: newContent })
 
560
  if (!selectedTaskId) return;
561
  setSavingTaskEdit(true);
562
  try {
563
+ const token = localStorage.getItem('token');
564
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
565
+ const viewMode = (localStorage.getItem('viewMode') || 'auto');
566
+ const effectiveRole = viewMode === 'student' ? 'student' : (user.role || 'visitor');
567
+
568
+ const headers: any = { 'Content-Type': 'application/json', 'x-user-name': username };
569
+ if (token) {
570
+ headers['Authorization'] = `Bearer ${token}`;
571
+ }
572
+ headers['x-user-role'] = effectiveRole;
573
+ headers['user-role'] = effectiveRole;
574
+
575
+ const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(selectedTaskId)}`), {
576
  method: 'PUT',
577
+ headers,
578
  body: JSON.stringify({ title: editTaskTitle, sourceText: editTaskSource })
579
  });
580
  const updated = await resp.json().catch(()=>({}));
 
586
  } finally {
587
  setSavingTaskEdit(false);
588
  }
589
+ }, [selectedTaskId, editTaskTitle, editTaskSource, username, getApiBase, isAdmin]);
590
 
591
  const [flowIndex, setFlowIndex] = React.useState<number>(0);
592
  // Keep URL hash in sync with primary navigation state
 
805
  const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
806
  const form = new FormData();
807
  form.append('file', file);
808
+ const parseEndpoint = getTutorialMode() ? '/api/tutorial-refinity/parse' : '/api/refinity/parse';
809
+ const resp = await fetch(`${base}${parseEndpoint}`, { method: 'POST', body: form });
810
  const data = await resp.json().catch(()=>({}));
811
  if (!resp.ok) throw new Error(data?.error || 'Failed to parse document');
812
  setNewTaskSource(String(data?.text || ''));
 
820
  const src = newTaskSource.trim();
821
  if (!title || !src) return;
822
  try {
 
823
  const body = { title, sourceText: src, createdBy: username };
824
+ if (getTutorialMode()) {
825
+ // In tutorial mode, include week number
826
+ const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0');
827
+ (body as any).weekNumber = weekNumber;
828
+ }
829
+ const token = localStorage.getItem('token');
830
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
831
+ const viewMode = (localStorage.getItem('viewMode') || 'auto');
832
+ const effectiveRole = viewMode === 'student' ? 'student' : (user.role || 'visitor');
833
+
834
+ const headers: any = { 'Content-Type': 'application/json', 'x-user-name': username };
835
+ if (token) {
836
+ headers['Authorization'] = `Bearer ${token}`;
837
+ }
838
+ // Always send role header
839
+ headers['x-user-role'] = effectiveRole;
840
+ headers['user-role'] = effectiveRole; // Also send without x- prefix for compatibility
841
+
842
+ const resp = await fetch(getApiBase('/tasks'), { method: 'POST', headers, body: JSON.stringify(body) });
843
  const saved = await resp.json().catch(()=>({}));
844
  if (!resp.ok) throw new Error(saved?.error || 'Save failed');
845
  const t: Task = { id: saved._id, title: saved.title, sourceText: saved.sourceText, createdBy: saved.createdBy };
 
882
 
883
  const submitPastedTranslation = async () => {
884
  const text = (pastedTranslation || '').trim();
885
+ if (!text || !task?.id) return;
886
  try {
887
+ const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(task.id)}/versions`), {
888
+ method: 'POST',
889
+ headers: { 'Content-Type': 'application/json' },
890
+ body: JSON.stringify({ originalAuthor: username, revisedBy: undefined, content: text })
891
+ });
892
  const saved = await resp.json().catch(()=>({}));
893
+ if (!resp.ok) {
894
+ console.error('Failed to save version:', saved?.error || 'Unknown error', saved);
895
+ throw new Error(saved?.error || 'Failed to save version');
896
+ }
897
+ const newVersion: Version = {
898
+ id: saved._id,
899
+ taskId: saved.taskId,
900
+ originalAuthor: saved.originalAuthor,
901
+ revisedBy: saved.revisedBy,
902
+ versionNumber: saved.versionNumber,
903
+ content: saved.content,
904
+ parentVersionId: saved.parentVersionId
905
+ };
906
  setVersions(prev => [...prev, newVersion]);
907
+ // Update cache
908
+ try {
909
+ const cacheKey = versionsCacheKey(task.id);
910
+ const cached = JSON.parse(sessionStorage.getItem(cacheKey) || '[]');
911
+ const updated = [...cached, newVersion];
912
+ sessionStorage.setItem(cacheKey, JSON.stringify(updated));
913
+ } catch {}
914
  setPastedTranslation('');
915
+ setCurrentVersionId(newVersion.id);
916
  setStage('flow');
917
+ } catch (e) {
918
+ console.error('Error saving draft translation:', e);
919
+ }
920
  };
921
 
922
  const selectManual = (id: string) => {
 
929
  if (!task?.id) return;
930
  const ok = window.confirm('Delete this Deep Revision task and all its versions? This cannot be undone.');
931
  if (!ok) return;
 
932
  const headers: any = { 'x-user-name': username };
933
  if (isAdmin) headers['x-user-role'] = 'admin';
934
+ const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(task.id)}`), { method: 'DELETE', headers });
935
  if (!resp.ok) throw new Error('Delete failed');
936
  setTasks(prev => prev.filter(t => t.id !== task.id));
937
  setVersions([]);
 
944
  } catch {}
945
  }, [isAdmin, task?.id, tasks, username]);
946
 
947
+ const handleSaveRevision = React.useCallback(async (newContent: string) => {
948
  const parent = taskVersions.find(v => v.id === currentVersionId);
949
+ if (!task?.id) return;
950
  try {
951
+ const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(task.id)}/versions`), {
 
952
  method: 'POST',
953
  headers: { 'Content-Type': 'application/json' },
954
  body: JSON.stringify({
 
959
  })
960
  });
961
  const saved = await resp.json().catch(()=>({}));
962
+ if (!resp.ok) {
963
+ console.error('Failed to save revision:', saved?.error || 'Unknown error', saved);
964
+ throw new Error(saved?.error || 'Save failed');
965
+ }
966
  const v: Version = {
967
  id: saved._id,
968
  taskId: saved.taskId,
 
973
  parentVersionId: saved.parentVersionId,
974
  };
975
  setVersions(prev => [...prev, v]);
976
+ // Update cache
977
+ try {
978
+ const cacheKey = versionsCacheKey(task.id);
979
+ const cached = JSON.parse(sessionStorage.getItem(cacheKey) || '[]');
980
+ const updated = [...cached, v];
981
+ sessionStorage.setItem(cacheKey, JSON.stringify(updated));
982
+ } catch {}
983
  setCurrentVersionId(v.id);
984
  // Navigate to flow to view the new version without clearing selection
985
  setStage('flow');
986
+ } catch (e) {
987
+ console.error('Error saving revision:', e);
988
  // no-op; keep user in editor if needed
989
  }
990
+ }, [task, taskVersions, currentVersionId, username, getApiBase, versionsCacheKey, getTutorialMode]);
991
 
992
  return (
993
  <div className={isFullscreen ? 'fixed inset-0 z-50 bg-white overflow-auto' : 'relative'}>
 
1148
  <div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
1149
  <span className="relative z-10">Start</span>
1150
  </button>
1151
+ {(!getTutorialMode() || isAdmin) && (
1152
  <button onClick={()=>setShowAddTask(v=>!v)} className="relative inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-violet-700 ring-1 ring-inset ring-violet-300 bg-white/20 backdrop-blur-md hover:bg-violet-50 active:translate-y-0.5 transition-all duration-200">
1153
  <span className="relative z-10">New Task</span>
1154
  </button>
1155
+ )}
1156
  {(isAdmin || String(username).toLowerCase()===String(tasks.find(t=>t.id===selectedTaskId)?.createdBy||'').toLowerCase()) && task?.id && (
1157
  <div className="flex items-center gap-2">
1158
  <button onClick={openEditTask} className="relative inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-gray-800 ring-1 ring-inset ring-gray-300 bg-white/20 backdrop-blur-md hover:bg-gray-100 active:translate-y-0.5 transition-all duration-200">
 
1504
  <div className="relative">
1505
  <button ref={compareBtnRef} onClick={(e)=>{ e.preventDefault(); const r=(e.currentTarget as HTMLElement).getBoundingClientRect(); setCompareMenuPos({ left: r.left, top: r.bottom + 8 }); setCompareDownloadOpen(v=>!v); }} disabled={!compareA || !compareB || compareA===compareB} className="px-3 py-2 text-sm rounded-md border border-gray-300 bg-white">Download ▾</button>
1506
  {compareDownloadOpen && compareMenuPos && createPortal(
1507
+ <div style={{ position: 'fixed', left: compareMenuPos.left, top: compareMenuPos.top, zIndex: 10000 }} className="w-52 rounded-md border border-gray-200 bg-white shadow-lg text-left">
1508
  <button onClick={async()=>{
1509
  setCompareDownloadOpen(false);
1510
  const a = taskVersions.find(v=>v.id===compareA);
 
1558
  link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);
1559
  } catch {}
1560
  }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Tracked Changes</button>
1561
+ <button onClick={async()=>{
1562
+ setCompareDownloadOpen(false);
1563
+ const a = taskVersions.find(v=>v.id===compareA);
1564
+ const b = taskVersions.find(v=>v.id===compareB);
1565
+ if (!a || !b || a.id===b.id) return;
1566
+ try {
1567
+ const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/,'');
1568
+ const older = (a.versionNumber <= b.versionNumber) ? a : b;
1569
+ const newer = (a.versionNumber <= b.versionNumber) ? b : a;
1570
+ const latestReviser = (newer.revisedBy || newer.originalAuthor || username || 'User');
1571
+ const A = older.versionNumber, B = newer.versionNumber;
1572
+ const filename = `${toSafeName(task?.title||'Task')}_Compare-v${A}-v${B}_CommentsSidebar_${yyyymmdd_hhmm()}_${toSafeName(latestReviser)}.docx`;
1573
+ const body: any = {
1574
+ prev: older.content || '',
1575
+ current: newer.content || '',
1576
+ filename,
1577
+ authorName: latestReviser,
1578
+ annotationVersionId: older.id,
1579
+ };
1580
+ let resp = await fetch(getApiBase('/compare-comments-with-corrections'), {
1581
+ method: 'POST',
1582
+ headers: { 'Content-Type': 'application/json' },
1583
+ body: JSON.stringify(body),
1584
+ });
1585
+ if (!resp.ok) {
1586
+ // Fallback to plain export of older text
1587
+ resp = await fetch(getApiBase('/export-plain'), {
1588
+ method: 'POST',
1589
+ headers: { 'Content-Type': 'application/json' },
1590
+ body: JSON.stringify({ current: older.content || '', filename }),
1591
+ });
1592
+ }
1593
+ if (!resp.ok) throw new Error('Export failed');
1594
+ const blob = await resp.blob();
1595
+ const url = window.URL.createObjectURL(blob);
1596
+ const link = document.createElement('a');
1597
+ link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);
1598
+ } catch {}
1599
+ }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Sidebar Comments</button>
1600
  </div>, document.body
1601
  )}
1602
  </div>
 
1641
  ` }} />
1642
  </div>
1643
  {compareModalOpen && (
1644
+ <div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" role="dialog" aria-modal="true" key="compare-modal">
1645
  <div className="relative max-w-5xl w-full max-h-[85vh] rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200 p-0 overflow-hidden">
1646
  <div className="flex items-center justify-between px-6 pt-5 pb-3 border-b sticky top-0 z-10 bg-white">
1647
  <div className="text-gray-800 font-medium">Diff</div>
 
1686
  link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);
1687
  } catch {}
1688
  }} className="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white">Export (Tracked Changes)</button>
1689
+ <button onClick={async()=>{
1690
+ try {
1691
+ const a = taskVersions.find(v=>v.id===lastDiffIds.a || v.id===compareA);
1692
+ const b = taskVersions.find(v=>v.id===lastDiffIds.b || v.id===compareB);
1693
+ if (!a || !b || a.id===b.id) return;
1694
+ const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/,'');
1695
+ const older = (a.versionNumber <= b.versionNumber) ? a : b;
1696
+ const newer = (a.versionNumber <= b.versionNumber) ? b : a;
1697
+ const latestReviser = (newer?.revisedBy || newer?.originalAuthor || username || 'User');
1698
+ const A = older.versionNumber, B = newer.versionNumber;
1699
+ const filename = `${toSafeName(task?.title||'Task')}_Compare-v${A}-v${B}_SidebarComments_${yyyymmdd_hhmm()}_${toSafeName(latestReviser)}.docx`;
1700
+ const body: any = {
1701
+ prev: older.content || '',
1702
+ current: newer.content || '',
1703
+ filename,
1704
+ authorName: latestReviser,
1705
+ annotationVersionId: older.id,
1706
+ };
1707
+ let resp = await fetch(getApiBase('/compare-comments-with-corrections'), {
1708
+ method: 'POST',
1709
+ headers: { 'Content-Type': 'application/json' },
1710
+ body: JSON.stringify(body),
1711
+ });
1712
+ if (!resp.ok) {
1713
+ // Fallback to plain export of older text
1714
+ resp = await fetch(getApiBase('/export-plain'), {
1715
+ method: 'POST',
1716
+ headers: { 'Content-Type': 'application/json' },
1717
+ body: JSON.stringify({ current: older.content || '', filename }),
1718
+ });
1719
+ }
1720
+ if (!resp.ok) throw new Error('Export failed');
1721
+ const blob = await resp.blob();
1722
+ const url = window.URL.createObjectURL(blob);
1723
+ const link = document.createElement('a');
1724
+ link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);
1725
+ } catch {}
1726
+ }} className="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white">Export (Sidebar Comments)</button>
1727
  <button onClick={()=>setCompareModalOpen(false)} className="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white">Close</button>
1728
  </div>
1729
  </div>
 
1763
  <EditorPane
1764
  source={task?.sourceText || ''}
1765
  initialTranslation={currentVersion?.content || ''}
1766
+ getApiBase={getApiBase}
1767
  onBack={()=>setStage('flow')}
1768
  onSave={handleSaveRevision}
1769
  onSaveEdit={editingVersionId ? ((text)=>saveEditedVersion(editingVersionId, text)) : undefined}
 
1782
  );
1783
  };
1784
 
1785
+ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack: ()=>void; onSave: (text: string)=>void; onSaveEdit?: (text: string)=>void; taskTitle: string; username: string; nextVersionNumber: number; isFullscreen: boolean; onToggleFullscreen: ()=>void; versionId: string; getApiBase: (endpoint: string) => string }>=({ source, initialTranslation, onBack, onSave, onSaveEdit, taskTitle, username, nextVersionNumber, isFullscreen, onToggleFullscreen, versionId, getApiBase })=>{
1786
  const [text, setText] = React.useState<string>(initialTranslation);
1787
  const [saving, setSaving] = React.useState(false);
1788
  // Sync text with incoming props when version/context changes (e.g., refresh -> data loads)
 
1793
  const [showDiff, setShowDiff] = React.useState<boolean>(false);
1794
  const [revDownloadOpen, setRevDownloadOpen] = React.useState<boolean>(false);
1795
  const [revMenuPos, setRevMenuPos] = React.useState<{ left: number; top: number } | null>(null);
1796
+ const [diffDownloadOpen, setDiffDownloadOpen] = React.useState<boolean>(false);
1797
+ const [diffMenuPos, setDiffMenuPos] = React.useState<{ left: number; top: number } | null>(null);
1798
  const revBtnRef = React.useRef<HTMLButtonElement | null>(null);
1799
+ const diffBtnRef = React.useRef<HTMLButtonElement | null>(null);
1800
  const sourceRef = React.useRef<HTMLDivElement>(null);
1801
  const [textareaHeight, setTextareaHeight] = React.useState('420px');
1802
  const [commentsOpen, setCommentsOpen] = React.useState(false);
 
1805
  const taRef = React.useRef<HTMLTextAreaElement | null>(null);
1806
  const overlayRef = React.useRef<HTMLDivElement | null>(null);
1807
  const wrapperRef = React.useRef<HTMLDivElement | null>(null);
1808
+ const annotatedRef = React.useRef<HTMLDivElement | null>(null);
1809
  // Annotation layer state (revision mode only)
1810
  const [showAnnotations, setShowAnnotations] = React.useState<boolean>(true);
 
 
 
 
 
 
 
 
1811
  const [toastMsg, setToastMsg] = React.useState<string>('');
1812
  const [dirty, setDirty] = React.useState<boolean>(false);
1813
  React.useEffect(() => { setDirty(false); }, [versionId, initialTranslation]);
 
1819
  return () => window.removeEventListener('beforeunload', handler);
1820
  }, [dirty]);
1821
  const showToast = (msg: string) => { setToastMsg(msg); setTimeout(()=>setToastMsg(''), 1800); };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1822
  const ANNO_STORE_KEY = 'refinity_annotations_v1';
1823
  type Ann = Annotation;
1824
  const loadAnnotations = React.useCallback((): Ann[] => {
 
1846
  const [editingAnn, setEditingAnn] = React.useState<Ann | null>(null);
1847
  const [modalCategory, setModalCategory] = React.useState<AnnotationCategory>('distortion');
1848
  const [modalComment, setModalComment] = React.useState('');
1849
+ const [modalCorrection, setModalCorrection] = React.useState('');
1850
+ const [modalIsDeletion, setModalIsDeletion] = React.useState(false);
1851
+ const [modalSelectedText, setModalSelectedText] = React.useState('');
1852
+ const [modalSourceSnippet, setModalSourceSnippet] = React.useState('');
1853
+ const [showSourcePane, setShowSourcePane] = React.useState(false);
1854
  const [popover, setPopover] = React.useState<{ left: number; top: number; start: number; end: number } | null>(null);
1855
 
1856
  function escapeHtml(s: string): string {
 
1861
  (async () => {
1862
  if (!versionId) return;
1863
  try {
1864
+ const resp = await fetch(getApiBase(`/annotations?versionId=${encodeURIComponent(versionId)}`));
 
1865
  const rows = await resp.json().catch(()=>[]);
1866
  if (Array.isArray(rows)) {
1867
  setAnnotations(prev => {
1868
  const others = prev.filter(a => a.versionId !== versionId);
1869
+ const loaded = rows.map((r:any)=>{
1870
+ const existing = prev.find(a => a.id === r._id);
1871
+ return {
1872
+ id: r._id,
1873
+ versionId: r.versionId,
1874
+ start: r.start,
1875
+ end: r.end,
1876
+ category: r.category,
1877
+ comment: r.comment,
1878
+ correction: existing?.correction,
1879
+ createdAt: r.createdAt,
1880
+ updatedAt: r.updatedAt,
1881
+ } as Ann;
1882
+ });
1883
  return [...others, ...loaded];
1884
  });
1885
  }
1886
  } catch {}
1887
  })();
1888
+ }, [versionId, getApiBase]);
1889
 
1890
  // Persistence helpers
1891
  const persistCreate = React.useCallback(async (a: Ann): Promise<Ann> => {
1892
  try {
1893
+ const body = { versionId: a.versionId, start: a.start, end: a.end, category: a.category, comment: a.comment, correction: a.correction };
1894
+ const resp = await fetch(getApiBase('/annotations'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
 
1895
  const row = await resp.json().catch(()=>({}));
1896
  if (resp.ok && row && row._id) return { ...a, id: row._id };
1897
  } catch {}
1898
  return a;
1899
+ }, [getApiBase]);
1900
  const persistUpdate = React.useCallback(async (a: Ann) => {
1901
  try {
1902
+ const body = { start: a.start, end: a.end, category: a.category, comment: a.comment, correction: a.correction };
1903
+ await fetch(getApiBase(`/annotations/${encodeURIComponent(a.id)}`), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
 
1904
  } catch {}
1905
+ }, [getApiBase]);
1906
  const persistDelete = React.useCallback(async (id: string) => {
1907
  try {
1908
+ await fetch(getApiBase(`/annotations/${encodeURIComponent(id)}`), { method: 'DELETE' });
 
1909
  } catch {}
1910
+ }, [getApiBase]);
1911
  function renderAnnotatedHtml(textValue: string): string {
1912
+ // Always wrap text in spans with data-start/data-end for selection offset calculation
1913
+ // even when annotations are disabled or empty
1914
+ if (!showAnnotations || !versionAnnotations.length) {
1915
+ return `<span data-start="0" data-end="${textValue.length}">${escapeHtml(textValue)}</span>`;
1916
+ }
1917
+
1918
  const list = versionAnnotations.slice().sort((a,b)=>a.start-b.start);
 
1919
  let html = ''; let pos = 0;
1920
  for (const a of list) {
1921
  const start = Math.max(0, Math.min(a.start, textValue.length));
1922
  const end = Math.max(start, Math.min(a.end, textValue.length));
1923
+ if (start > pos) {
1924
+ const plain = textValue.slice(pos, start);
1925
+ if (plain) {
1926
+ html += `<span data-start="${pos}" data-end="${start}">${escapeHtml(plain)}</span>`;
1927
+ }
1928
+ }
1929
  const cls = CATEGORY_CLASS[a.category] || 'bg-gray-100';
1930
+ const spanText = escapeHtml(textValue.slice(start, end)) || '&nbsp;';
1931
+ html += `<span data-anno-id="${a.id}" data-start="${start}" data-end="${end}" class="inline rounded-[4px] ${cls}" title="${a.category}${a.comment ? ': '+escapeHtml(a.comment) : ''}">${spanText}</span>`;
1932
  pos = end;
1933
  }
1934
+ if (pos < textValue.length) {
1935
+ const tail = textValue.slice(pos);
1936
+ if (tail) {
1937
+ html += `<span data-start="${pos}" data-end="${textValue.length}">${escapeHtml(tail)}</span>`;
1938
+ }
1939
+ }
1940
  return html;
1941
  }
1942
+
1943
+ // Helper: find paragraph snippet in source text that roughly corresponds to the
1944
+ // paragraph containing the given offset in the translation text.
1945
+ function computeSourceSnippetForOffset(sourceText: string, translationText: string, offset: number): string {
1946
+ if (!sourceText || !translationText) return '';
1947
+ type Range = { start: number; end: number };
1948
+ const makeRanges = (textValue: string): Range[] => {
1949
+ const ranges: Range[] = [];
1950
+ let start = 0;
1951
+ const len = textValue.length;
1952
+ for (let i = 0; i < len; i++) {
1953
+ // Treat two or more consecutive newlines as a paragraph separator
1954
+ if (textValue[i] === '\n' && textValue[i + 1] === '\n') {
1955
+ ranges.push({ start, end: i });
1956
+ // Skip all consecutive newlines
1957
+ let j = i + 1;
1958
+ while (j < len && textValue[j] === '\n') j++;
1959
+ start = j;
1960
+ i = j - 1;
1961
+ } else if (i === len - 1) {
1962
+ ranges.push({ start, end: len });
1963
+ }
1964
+ }
1965
+ if (!ranges.length) ranges.push({ start: 0, end: len });
1966
+ return ranges;
1967
+ };
1968
+ const trRanges = makeRanges(translationText);
1969
+ const srcRanges = makeRanges(sourceText);
1970
+ const idx = trRanges.findIndex(r => offset >= r.start && offset <= r.end);
1971
+ const srcIdx = idx >= 0 ? Math.min(idx, srcRanges.length - 1) : 0;
1972
+ const r = srcRanges[srcIdx];
1973
+ return sourceText.slice(r.start, r.end).trim();
1974
+ }
1975
  // Adjust annotations when text changes so highlights track or are removed
1976
  const adjustAnnotationsForEdit = React.useCallback((oldText: string, newText: string) => {
1977
  if (oldText === newText) return;
 
2002
  return [...others, ...adjusted];
2003
  });
2004
  }, [versionId, setAnnotations]);
2005
+
2006
+ // Map current selection within annotatedRef to plain-text offsets in `text`,
2007
+ // using data-start/data-end spans emitted by renderAnnotatedHtml for exact mapping.
2008
+ function getOffsetsFromSelection(): { start: number; end: number; rect: DOMRect | null } | null {
2009
+ const container = annotatedRef.current;
2010
+ if (!container) return null;
2011
+ const sel = window.getSelection();
2012
+ if (!sel || sel.rangeCount === 0) return null;
2013
+ const range = sel.getRangeAt(0);
2014
+ if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) return null;
2015
+
2016
+ const resolveOffset = (node: Node, offset: number): number | null => {
2017
+ // Find the containing span with data-start attribute
2018
+ let el: HTMLElement | null = (node.nodeType === Node.ELEMENT_NODE
2019
+ ? node as HTMLElement
2020
+ : node.parentElement as HTMLElement | null);
2021
+
2022
+ while (el && el !== container && !el.hasAttribute('data-start')) {
2023
+ el = el.parentElement;
2024
+ }
2025
+
2026
+ if (!el || !el.hasAttribute('data-start')) {
2027
+ // Fallback: try to find any span with data-start in the container
2028
+ const allSpans = container.querySelectorAll('[data-start]');
2029
+ if (allSpans.length === 0) return null;
2030
+ // Use first span as fallback
2031
+ el = allSpans[0] as HTMLElement;
2032
+ }
2033
+
2034
+ const baseStart = Number(el.getAttribute('data-start') || '0') || 0;
2035
+
2036
+ // Create a range from the start of this span to the target node/offset
2037
+ const r = document.createRange();
2038
+ try {
2039
+ r.selectNodeContents(el);
2040
+ r.setEnd(node, offset);
2041
+ } catch (e) {
2042
+ // If setting end fails, return baseStart as fallback
2043
+ return baseStart;
2044
+ }
2045
+
2046
+ const localLen = r.toString().length;
2047
+ return baseStart + localLen;
2048
+ };
2049
+
2050
+ const start = resolveOffset(range.startContainer, range.startOffset);
2051
+ const end = resolveOffset(range.endContainer, range.endOffset);
2052
+
2053
+ if (start == null || end == null) {
2054
+ // Fallback: walk text nodes to calculate offset
2055
+ try {
2056
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
2057
+ let charCount = 0;
2058
+ let startOffset = -1;
2059
+ let endOffset = -1;
2060
+
2061
+ while (walker.nextNode()) {
2062
+ const node = walker.currentNode as Text;
2063
+ const nodeText = node.nodeValue || '';
2064
+
2065
+ if (node === range.startContainer) {
2066
+ startOffset = charCount + range.startOffset;
2067
+ }
2068
+ if (node === range.endContainer) {
2069
+ endOffset = charCount + range.endOffset;
2070
+ break;
2071
+ }
2072
+
2073
+ charCount += nodeText.length;
2074
+ }
2075
+
2076
+ if (startOffset >= 0 && endOffset >= 0 && endOffset > startOffset) {
2077
+ const rect = range.getBoundingClientRect();
2078
+ return { start: startOffset, end: endOffset, rect };
2079
+ }
2080
+ } catch (e) {
2081
+ console.debug('[getOffsetsFromSelection] fallback failed:', e);
2082
+ }
2083
+ return null;
2084
  }
2085
+
2086
+ const rect = range.getBoundingClientRect();
2087
+ return { start, end, rect };
 
 
 
2088
  }
2089
+
2090
  const updateSelectionPopover = React.useCallback(() => {
2091
+ const wrap = wrapperRef.current;
2092
+ const container = annotatedRef.current;
2093
+ if (!wrap || !container) {
2094
+ setPopover(null);
2095
+ return;
2096
+ }
2097
+
2098
+ const sel = window.getSelection();
2099
+ if (!sel || sel.rangeCount === 0) {
2100
+ setPopover(null);
2101
+ return;
2102
+ }
2103
+
2104
+ const res = getOffsetsFromSelection();
2105
+ if (!res) {
2106
+ setPopover(null);
2107
+ return;
2108
+ }
2109
+
2110
+ const { start, end, rect } = res;
2111
+ if (!rect) {
2112
+ setPopover(null);
2113
+ return;
2114
+ }
2115
+
2116
+ if (end <= start) {
2117
+ setPopover(null);
2118
+ return;
2119
+ }
2120
+
2121
  const wrect = wrap.getBoundingClientRect();
2122
  const btnW = 120, btnH = 30, padding = 6;
2123
  // Center over the selection; clamp within container
 
2128
  if (top < padding) {
2129
  top = rect.bottom - wrect.top + padding;
2130
  }
2131
+
2132
+ setPopover({ left, top, start, end });
2133
  }, []);
2134
 
2135
  React.useEffect(() => {
 
2161
  return () => window.removeEventListener('resize', onResize);
2162
  }, []);
2163
 
2164
+ // Keep overlay aligned and font-synced with textarea across renders/toggles
2165
+ React.useLayoutEffect(() => {
2166
+ const ta = taRef.current;
2167
+ const ov = overlayRef.current;
2168
+ if (!ta || !ov) return;
2169
+ // Scroll alignment
2170
+ ov.style.transform = `translateY(-${ta.scrollTop}px)`;
2171
+ // Font/layout alignment
2172
+ try {
2173
+ const cs = window.getComputedStyle(ta);
2174
+ ov.style.fontFamily = cs.fontFamily;
2175
+ ov.style.fontSize = cs.fontSize;
2176
+ ov.style.lineHeight = cs.lineHeight;
2177
+ ov.style.letterSpacing = cs.letterSpacing;
2178
+ ov.style.whiteSpace = cs.whiteSpace;
2179
+ } catch {}
2180
+ }, [showAnnotations, text, textareaHeight, isFullscreen]);
2181
+
2182
+ // Build corrected text from original text and annotation corrections
2183
+ const buildCorrectedText = React.useCallback((): string => {
2184
+ const base = initialTranslation || '';
2185
+ if (!versionAnnotations.length) return base;
2186
+ const anns = versionAnnotations
2187
+ .filter(a => typeof a.correction === 'string' && a.correction.trim().length > 0)
2188
+ .slice()
2189
+ .sort((a, b) => a.start - b.start || a.end - b.end);
2190
+ if (!anns.length) return base;
2191
+ let result = '';
2192
+ let pos = 0;
2193
+ for (const a of anns) {
2194
+ const start = Math.max(0, Math.min(a.start, base.length));
2195
+ const end = Math.max(start, Math.min(a.end, base.length));
2196
+ if (start < pos) {
2197
+ // overlapping or out-of-order; skip this annotation to avoid corrupting text
2198
+ continue;
2199
+ }
2200
+ if (pos < start) {
2201
+ result += base.slice(pos, start);
2202
+ }
2203
+ result += a.correction as string;
2204
+ pos = end;
2205
+ }
2206
+ if (pos < base.length) {
2207
+ result += base.slice(pos);
2208
+ }
2209
+ return result;
2210
+ }, [initialTranslation, versionAnnotations]);
2211
+
2212
  // Keep overlay aligned with textarea scroll position across renders/toggles
2213
  React.useLayoutEffect(() => {
2214
  const ta = taRef.current;
 
2221
  setSaving(true);
2222
  try {
2223
  if (onSaveEdit) {
2224
+ // Editing mode: save the text as-is
2225
  await onSaveEdit(text);
2226
  } else {
2227
+ // Revision mode: apply all corrections from annotations before saving
2228
+ const correctedText = buildCorrectedText();
2229
+ onSave(correctedText);
2230
  }
2231
  } finally {
2232
  setSaving(false);
 
2236
  const compareNow = async ()=>{
2237
  try {
2238
  const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
2239
+ // Use corrected text for comparison if in revision mode (not editing mode)
2240
+ const currentText = onSaveEdit ? text : buildCorrectedText();
2241
+ const resp = await fetch(`${base}/api/refinity/diff`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prev: initialTranslation || '', current: currentText || '' }) });
2242
  const data = await resp.json().catch(()=>({}));
2243
  if (!resp.ok) throw new Error(data?.error || 'Diff failed');
2244
  // Ensure layout preserved if backend HTML lacks <br/>
 
2256
  const userNameSafe = toSafeName(username || 'User');
2257
  const vNum = `v${nextVersionNumber}`;
2258
  const filename = `${taskNameSafe}_${vNum}_Clean_${yyyymmdd_hhmm()}_${userNameSafe}.docx`;
2259
+ // Use corrected text if in revision mode
2260
+ const currentText = onSaveEdit ? text : buildCorrectedText();
2261
+ const resp = await fetch(`${base}/api/refinity/track-changes-ooxml`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prev: initialTranslation || '', current: currentText || '', filename, includeComments: false }) });
2262
  if (!resp.ok) throw new Error('Export failed');
2263
  const blob = await resp.blob();
2264
  const url = window.URL.createObjectURL(blob);
 
2278
  <div>
2279
  <div className="flex items-start gap-6">
2280
  <div className="w-1/2">
2281
+ <div className="mb-2 text-gray-700 text-sm flex items-center justify-between">
2282
+ <span>Source</span>
2283
+ <div className="flex items-center gap-2">
2284
+ {!isFullscreen && (
2285
+ <button
2286
+ className="inline-flex items-center px-2 py-1 text-xs rounded-xl opacity-0 pointer-events-none"
2287
+ >
2288
+ Full Screen
2289
+ </button>
2290
+ )}
2291
+ </div>
2292
+ </div>
2293
  <div className="relative rounded-lg overflow-hidden">
2294
  <div className="absolute inset-0 rounded-lg" style={{ background: 'radial-gradient(120%_120%_at_0%_0%, #dbeafe 0%, #ffffff 50%, #c7d2fe 100%)' }} />
2295
  <div ref={sourceRef} className="relative rounded-lg bg-white/60 backdrop-blur-md ring-1 ring-inset ring-indigo-300 shadow p-4 min-h-[420px] whitespace-pre-wrap text-gray-900">
 
2301
  <div className="mb-2 text-gray-700 text-sm flex items-center justify-between">
2302
  <span>Translation</span>
2303
  <div className="flex items-center gap-2">
 
 
 
 
2304
  {!isFullscreen && (
2305
  <button onClick={onToggleFullscreen} className="inline-flex items-center px-2 py-1 text-xs rounded-xl bg-white/60 ring-1 ring-gray-200 text-gray-800 hover:bg-white">
2306
  Full Screen
 
2308
  )}
2309
  </div>
2310
  </div>
2311
+ <div ref={wrapperRef} className="relative overflow-hidden">
2312
+ {/* Single-surface annotated view (exact alignment) */}
2313
+ {onSaveEdit ? (
2314
+ // Editing mode: use textarea for direct editing
2315
+ <textarea
2316
+ value={text}
2317
+ onChange={(e) => {
2318
+ setText(e.target.value);
2319
+ setDirty(true);
2320
+ }}
2321
+ className="relative z-10 w-full px-4 py-3 border border-ui-border rounded-lg bg-white text-base leading-relaxed font-sans tracking-normal whitespace-pre-wrap overflow-y-auto resize-none"
2322
+ style={{
2323
+ minHeight: textareaHeight,
2324
+ height: textareaHeight,
2325
+ maxHeight: textareaHeight,
2326
+ }}
2327
+ />
2328
  ) : (
2329
+ // Revision mode: use annotated div
2330
+ <div
2331
+ ref={annotatedRef}
2332
+ className="relative z-10 w-full px-4 py-3 border border-ui-border rounded-lg bg-white text-base leading-relaxed font-sans tracking-normal whitespace-pre-wrap overflow-y-auto select-text"
2333
+ style={{
2334
+ minHeight: textareaHeight,
2335
+ height: textareaHeight,
2336
+ maxHeight: textareaHeight,
2337
+ userSelect: 'text',
2338
+ WebkitUserSelect: 'text',
2339
+ }}
2340
+ onMouseDown={(e) => {
2341
+ // Don't clear popover on mousedown - let mouseup handle it
2342
+ // This allows selection to work properly
2343
+ }}
2344
+ onMouseUp={(e) => {
2345
+ // Use setTimeout to ensure selection is stable
2346
+ setTimeout(() => {
2347
+ const sel = window.getSelection();
2348
+ if (!sel || sel.rangeCount === 0) {
2349
+ setPopover(null);
2350
+ return;
2351
+ }
2352
+
2353
+ const res = getOffsetsFromSelection();
2354
+ if (!res) {
2355
+ setPopover(null);
2356
+ return;
2357
+ }
2358
+
2359
+ const { start, end } = res;
2360
+ if (end - start <= 0) {
2361
+ // collapsed caret: if inside an existing annotation, open edit modal
2362
+ const hit = versionAnnotations.find(a => start >= a.start && start <= a.end);
2363
+ if (hit) {
2364
+ setEditingAnn(hit);
2365
+ setModalCategory(hit.category);
2366
+ setModalComment(hit.comment || '');
2367
+ const selected = text.slice(hit.start, hit.end);
2368
+ setModalSelectedText(selected);
2369
+ setModalCorrection(hit.correction ?? selected);
2370
+ setModalIsDeletion(false);
2371
+ setModalSourceSnippet(
2372
+ computeSourceSnippetForOffset(source, text, hit.start)
2373
+ );
2374
+ setShowSourcePane(false);
2375
+ setModalOpen(true);
2376
+ return;
2377
+ }
2378
+ setPopover(null);
2379
+ return;
2380
+ }
2381
+ updateSelectionPopover();
2382
+ }, 10);
2383
+ }}
2384
+ onKeyUp={()=>{
2385
+ setTimeout(() => {
2386
+ updateSelectionPopover();
2387
+ }, 10);
2388
+ }}
2389
+ dangerouslySetInnerHTML={{ __html: renderAnnotatedHtml(text) }}
2390
+ />
2391
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2392
  {/* + Comment popover */}
2393
  {popover && (
2394
  <div className="absolute z-20" style={{ left: popover.left, top: popover.top }}>
 
2398
  setEditingAnn(null);
2399
  setModalCategory('distortion');
2400
  setModalComment('');
2401
+ if (popover) {
2402
+ const sel = text.slice(popover.start, popover.end);
2403
+ setModalSelectedText(sel);
2404
+ setModalCorrection(sel);
2405
+ setModalIsDeletion(false);
2406
+ setModalSourceSnippet(
2407
+ computeSourceSnippetForOffset(source, text, popover.start)
2408
+ );
2409
+ } else {
2410
+ setModalSelectedText('');
2411
+ setModalCorrection('');
2412
+ setModalIsDeletion(false);
2413
+ setModalSourceSnippet('');
2414
+ }
2415
+ setShowSourcePane(false);
2416
  setModalOpen(true);
2417
  }}
2418
  className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-emerald-700 active:translate-y-0.5 transition-all duration-200"
 
2426
  </div>
2427
  <div className="mt-4 flex gap-3 relative">
2428
  <button
2429
+ onClick={() => { setDirty(false); save(); showToast('Saved'); }}
2430
+ disabled={saving}
2431
+ className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-indigo-600/70 hover:bg-indigo-700 disabled:bg-gray-400 active:translate-y-0.5 transition-all duration-200"
 
 
 
2432
  >
2433
  <div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" />
2434
  <div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
2435
+ {saving? 'Saving…':'Save'}
 
 
 
 
 
2436
  </button>
2437
  <div className="relative inline-block align-top">
2438
  <button
 
2453
  </button>
2454
  {revDownloadOpen && revMenuPos && createPortal(
2455
  <div style={{ position: 'fixed', left: revMenuPos.left, top: revMenuPos.top, zIndex: 10000, maxHeight: '240px', overflowY: 'auto' }} className="w-56 rounded-md border border-gray-200 bg-white shadow-lg text-left">
2456
+ <button onClick={async()=>{ setRevDownloadOpen(false); try { const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const filename=`${toSafeName(taskTitle||'Task')}_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`; const currentText = onSaveEdit ? text : buildCorrectedText(); const body={ current: currentText||'', filename }; let resp=await fetch(`${base}/api/refinity/export-plain`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body) }); if(!resp.ok) throw new Error('Export failed'); const blob=await resp.blob(); await saveBlobToDisk(blob, filename);} catch { const currentText = onSaveEdit ? text : buildCorrectedText(); await saveTextFallback(currentText||'', `${toSafeName(taskTitle||'Task')}_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.txt`);} }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Without Annotations</button>
2457
+ <button onClick={async()=>{ setRevDownloadOpen(false); try { const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const filename=`${toSafeName(taskTitle||'Task')}_v${nextVersionNumber}_Annotated_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`; const currentText = onSaveEdit ? text : buildCorrectedText(); const list=versionAnnotations.map(a=>({ start:a.start, end:a.end, category:a.category, comment:a.comment })); const body={ current: currentText||'', filename, annotations: list }; let resp=await fetch(`${base}/api/refinity/export-plain-with-annotations`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body) }); if(!resp.ok){ resp=await fetch(`${base}/api/refinity/export-plain`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ current: currentText||'', filename }) }); } if(!resp.ok) throw new Error('Export failed'); const blob=await resp.blob(); await saveBlobToDisk(blob, filename);} catch { const currentText = onSaveEdit ? text : buildCorrectedText(); await saveTextFallback(currentText||'', `${toSafeName(taskTitle||'Task')}_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.txt`);} }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">With Annotations</button>
2458
  </div>, document.body
2459
  )}
2460
  </div>
 
2463
  </div>
2464
  {/* Inline comments drawer removed */}
2465
  {showDiff && (
2466
+ <div className="mt-6">
2467
+ <div className="relative rounded-xl">
2468
+ <div className="absolute inset-0 rounded-xl bg-gradient-to-r from-indigo-200/45 via-indigo-100/40 to-indigo-300/45" />
2469
+ <div className="relative rounded-xl bg-white/10 backdrop-blur-md ring-1 ring-inset ring-white/30 shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)] p-4">
2470
+ <div className="pointer-events-none absolute inset-0 rounded-xl opacity-50 [background:linear-gradient(to_bottom,rgba(255,255,255,0.3),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.28),rgba(255,255,255,0)_28%)]" />
2471
+ <div className="pointer-events-none absolute inset-0 rounded-xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-45" />
2472
+ <div className="relative text-gray-900 prose prose-sm max-w-none whitespace-pre-wrap" dangerouslySetInnerHTML={{ __html: diffHtml }} />
2473
+ </div>
2474
+ </div>
2475
+ <div className="mt-4 flex justify-end">
2476
+ <div className="relative inline-block">
2477
+ <button
2478
+ ref={diffBtnRef}
2479
+ onClick={(e)=>{
2480
+ e.preventDefault(); e.stopPropagation();
2481
+ const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
2482
+ const pad = 8, menuW = 200;
2483
+ let left = Math.min(Math.max(r.left, pad), window.innerWidth - menuW - pad);
2484
+ let top = r.bottom + pad;
2485
+ if (top + 150 > window.innerHeight - pad) top = r.top - 150 - pad;
2486
+ setDiffMenuPos({ left, top });
2487
+ setDiffDownloadOpen(o=>!o);
2488
+ }}
2489
+ className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-black ring-1 ring-inset ring-white/50 backdrop-blur-md bg-white/30 active:translate-y-0.5 transition-all duration-200"
2490
+ >
2491
+ Download ▾
2492
+ </button>
2493
+ {diffDownloadOpen && diffMenuPos && createPortal(
2494
+ <div style={{ position: 'fixed', left: diffMenuPos.left, top: diffMenuPos.top, zIndex: 10000 }} className="w-52 rounded-md border border-gray-200 bg-white shadow-lg text-left">
2495
+ <button onClick={async()=>{
2496
+ setDiffDownloadOpen(false);
2497
+ try {
2498
+ const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
2499
+ const correctedText = buildCorrectedText();
2500
+ const filename = `${toSafeName(taskTitle||'Task')}_Compare_Inline_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`;
2501
+ const body = { prev: initialTranslation||'', current: correctedText||'', filename, authorName: username||'User' };
2502
+ let resp = await fetch(`${base}/api/refinity/track-changes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
2503
+ if (!resp.ok) {
2504
+ resp = await fetch(`${base}/api/refinity/export-plain`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current: correctedText||'', filename }) });
2505
+ }
2506
+ if (!resp.ok) throw new Error('Export failed');
2507
+ const blob = await resp.blob();
2508
+ const url = window.URL.createObjectURL(blob);
2509
+ const link = document.createElement('a');
2510
+ link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);
2511
+ } catch {}
2512
+ }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Inline Changes</button>
2513
+ <button onClick={async()=>{
2514
+ setDiffDownloadOpen(false);
2515
+ try {
2516
+ const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
2517
+ const correctedText = buildCorrectedText();
2518
+ const filename = `${toSafeName(taskTitle||'Task')}_Compare_Tracked_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`;
2519
+ const body = { prev: initialTranslation||'', current: correctedText||'', filename, authorName: username||'User', includeComments: false };
2520
+ let resp = await fetch(`${base}/api/refinity/track-changes-ooxml`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
2521
+ if (!resp.ok) {
2522
+ resp = await fetch(`${base}/api/refinity/export-plain`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current: correctedText||'', filename }) });
2523
+ }
2524
+ if (!resp.ok) throw new Error('Export failed');
2525
+ const blob = await resp.blob();
2526
+ const url = window.URL.createObjectURL(blob);
2527
+ const link = document.createElement('a');
2528
+ link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);
2529
+ } catch {}
2530
+ }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Tracked Changes</button>
2531
+ <button onClick={async()=>{
2532
+ setDiffDownloadOpen(false);
2533
+ try {
2534
+ const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/,'');
2535
+ const correctedText = buildCorrectedText();
2536
+ const filename = `${toSafeName(taskTitle||'Task')}_Compare_SidebarComments_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`;
2537
+ const body: any = {
2538
+ prev: initialTranslation || '',
2539
+ current: correctedText || '',
2540
+ filename,
2541
+ authorName: username || 'User',
2542
+ annotationVersionId: versionId,
2543
+ };
2544
+ let resp = await fetch(getApiBase('/compare-comments-with-corrections'), {
2545
+ method: 'POST',
2546
+ headers: { 'Content-Type': 'application/json' },
2547
+ body: JSON.stringify(body),
2548
+ });
2549
+ if (!resp.ok) {
2550
+ resp = await fetch(getApiBase('/export-plain'), {
2551
+ method: 'POST',
2552
+ headers: { 'Content-Type': 'application/json' },
2553
+ body: JSON.stringify({ current: initialTranslation || '', filename }),
2554
+ });
2555
+ }
2556
+ if (!resp.ok) throw new Error('Export failed');
2557
+ const blob = await resp.blob();
2558
+ const url = window.URL.createObjectURL(blob);
2559
+ const link = document.createElement('a');
2560
+ link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);
2561
+ } catch {}
2562
+ }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Sidebar Comments</button>
2563
+ </div>, document.body
2564
+ )}
2565
+ </div>
2566
  </div>
2567
  </div>
2568
  )}
 
2574
  {/* Annotation modal */}
2575
  {modalOpen && (
2576
  <div className="fixed inset-0 z-[5000] flex items-center justify-center bg-black/40">
2577
+ <motion.div
2578
+ initial={false}
2579
+ animate={{ maxWidth: showSourcePane && modalSourceSnippet ? 900 : 480 }}
2580
+ transition={{ duration: 0.2, ease: 'easeOut' }}
2581
+ className="bg-white rounded-xl shadow-xl w-full p-6"
2582
+ >
2583
+ <div className="flex items-center justify-between mb-4">
2584
+ <h3 className="text-lg font-semibold text-gray-900">{editingAnn ? 'Edit annotation' : 'New annotation'}</h3>
2585
+ {modalSourceSnippet && (
2586
+ <button
2587
+ type="button"
2588
+ onClick={() => setShowSourcePane(v => !v)}
2589
+ className="text-xs text-indigo-700 hover:text-indigo-900 underline-offset-2 hover:underline"
2590
  >
2591
+ {showSourcePane ? 'Hide source' : 'Show source'}
2592
+ </button>
2593
+ )}
2594
+ </div>
2595
+ <div className="space-y-4">
2596
+ <div className="md:flex md:space-x-4 space-y-4 md:space-y-0 items-start">
2597
+ <AnimatePresence initial={false}>
2598
+ {showSourcePane && modalSourceSnippet && (
2599
+ <motion.div
2600
+ key="source-pane"
2601
+ initial={{ opacity: 0, x: -10 }}
2602
+ animate={{ opacity: 1, x: 0 }}
2603
+ exit={{ opacity: 0, x: -10 }}
2604
+ transition={{ duration: 0.2, ease: 'easeOut' }}
2605
+ className="md:w-2/5 w-full space-y-1"
2606
+ >
2607
+ <label className="block text-sm text-gray-700">
2608
+ Source paragraph
2609
+ </label>
2610
+ <div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm text-gray-800 whitespace-pre-wrap overflow-auto max-h-64">
2611
+ {modalSourceSnippet}
2612
+ </div>
2613
+ </motion.div>
2614
+ )}
2615
+ </AnimatePresence>
2616
+ <div className="flex-1 md:w-3/5 space-y-4">
2617
+ <div>
2618
+ <label className="block text-sm text-gray-700 mb-1">Selected text</label>
2619
+ <div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm text-gray-800 whitespace-pre-wrap max-h-32 overflow-auto">
2620
+ {modalSelectedText || '(no selection)'}
2621
+ </div>
2622
+ </div>
2623
+ <div>
2624
+ <label className="block text-sm text-gray-700 mb-1">Error type <span className="text-red-500">*</span></label>
2625
+ <select
2626
+ value={modalCategory}
2627
+ onChange={(e)=>setModalCategory(e.target.value as AnnotationCategory)}
2628
+ className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm"
2629
+ >
2630
+ <option value="distortion">Distortion</option>
2631
+ <option value="omission">Unjustified omission</option>
2632
+ <option value="register">Inappropriate register</option>
2633
+ <option value="unidiomatic">Unidiomatic expression</option>
2634
+ <option value="grammar">Error of grammar, syntax</option>
2635
+ <option value="spelling">Error of spelling</option>
2636
+ <option value="punctuation">Error of punctuation</option>
2637
+ <option value="addition">Unjustified addition</option>
2638
+ <option value="other">Other</option>
2639
+ </select>
2640
+ </div>
2641
+ <div>
2642
+ <div className="mb-3">
2643
+ <label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
2644
+ <input
2645
+ type="checkbox"
2646
+ checked={modalIsDeletion}
2647
+ onChange={(e) => {
2648
+ setModalIsDeletion(e.target.checked);
2649
+ if (e.target.checked) {
2650
+ setModalCorrection(''); // Clear correction when marking as deletion
2651
+ }
2652
+ }}
2653
+ className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
2654
+ />
2655
+ <span>Delete this text</span>
2656
+ </label>
2657
+ </div>
2658
+ {!modalIsDeletion && (
2659
+ <>
2660
+ <label className="block text-sm text-gray-700 mb-1">Correction <span className="text-red-500">*</span></label>
2661
+ <textarea
2662
+ value={modalCorrection}
2663
+ onChange={(e)=>setModalCorrection(e.target.value)}
2664
+ rows={2}
2665
+ className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm mb-3"
2666
+ placeholder="Enter the corrected wording for this span…"
2667
+ />
2668
+ </>
2669
+ )}
2670
+ {modalIsDeletion && (
2671
+ <div className="mb-3 p-3 bg-red-50 border border-red-200 rounded-md">
2672
+ <p className="text-sm text-red-700">This text will be deleted. The selected text will be removed from the final version.</p>
2673
+ </div>
2674
+ )}
2675
+ <label className="block text-sm text-gray-700 mb-1">Comment (optional)</label>
2676
+ <textarea
2677
+ value={modalComment}
2678
+ onChange={(e)=>setModalComment(e.target.value)}
2679
+ rows={3}
2680
+ className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm"
2681
+ placeholder="Enter the explanation or feedback…"
2682
+ />
2683
+ </div>
2684
+ </div>
2685
  </div>
2686
  </div>
2687
  <div className="mt-6 flex items-center justify-between">
2688
  {editingAnn && (
2689
+ <button onClick={async()=>{ await persistDelete(editingAnn.id); deleteAnnotationById(editingAnn.id); setModalOpen(false); setPopover(null); setEditingAnn(null); setModalIsDeletion(false); }} className="px-3 py-1.5 text-sm rounded-lg text-white bg-red-600 hover:bg-red-700">Delete</button>
2690
  )}
2691
  <div className="ml-auto flex gap-2">
2692
+ <button onClick={()=>{ setModalOpen(false); setPopover(null); setEditingAnn(null); setModalIsDeletion(false); }} className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 text-gray-700 bg-white hover:bg-gray-50">Cancel</button>
2693
  <button
2694
  onClick={async ()=>{
2695
+ // Allow empty correction only if deletion is checked
2696
+ if (!modalIsDeletion && !modalCorrection.trim()) {
2697
+ alert('Please enter a correction for this highlighted text, or check "Delete this text" to mark it for deletion.');
2698
+ return;
2699
+ }
2700
  if (editingAnn) {
2701
+ const updated = {
2702
+ ...editingAnn,
2703
+ category: modalCategory,
2704
+ comment: modalComment,
2705
+ correction: modalIsDeletion ? '' : modalCorrection,
2706
+ updatedAt: Date.now()
2707
+ };
2708
  updateAnnotation(updated);
2709
  await persistUpdate(updated);
2710
  } else if (popover) {
2711
+ const local = {
2712
+ id:`a_${Date.now()}_${Math.random().toString(36).slice(2,7)}`,
2713
+ versionId,
2714
+ start: popover.start,
2715
+ end: popover.end,
2716
+ category: modalCategory,
2717
+ comment: modalComment,
2718
+ correction: modalIsDeletion ? '' : modalCorrection,
2719
+ createdAt: Date.now(),
2720
+ updatedAt: Date.now()
2721
+ };
2722
  const saved = await persistCreate(local);
2723
  addAnnotation(saved);
2724
  }
2725
+ setModalOpen(false); setPopover(null); setEditingAnn(null); setModalIsDeletion(false);
2726
  }}
2727
  className="px-3 py-1.5 text-sm rounded-lg text-white bg-indigo-600 hover:bg-indigo-700"
2728
  >
 
2730
  </button>
2731
  </div>
2732
  </div>
2733
+ </motion.div>
2734
  </div>
2735
  )}
2736
  </div>
client/src/components/TutorialRefinity.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import Refinity from './Refinity';
3
+
4
+ interface TutorialRefinityProps {
5
+ weekNumber: number;
6
+ }
7
+
8
+ /**
9
+ * Tutorial-specific wrapper for Deep Revision (Refinity)
10
+ * - Only admin can create/edit tasks
11
+ * - Users can only access admin-created tasks
12
+ * - Users cannot see other users' revisions (individual practice)
13
+ * - Uses separate backend storage for tutorial data
14
+ */
15
+ const TutorialRefinity: React.FC<TutorialRefinityProps> = ({ weekNumber }) => {
16
+ // Set tutorial mode in localStorage synchronously BEFORE rendering Refinity
17
+ // This ensures Refinity detects tutorial mode on first render
18
+ React.useLayoutEffect(() => {
19
+ localStorage.setItem('refinityMode', 'tutorial');
20
+ localStorage.setItem('tutorialWeekNumber', weekNumber.toString());
21
+ return () => {
22
+ localStorage.removeItem('refinityMode');
23
+ localStorage.removeItem('tutorialWeekNumber');
24
+ };
25
+ }, [weekNumber]);
26
+
27
+ // Also set it synchronously on first render
28
+ if (typeof window !== 'undefined') {
29
+ try {
30
+ localStorage.setItem('refinityMode', 'tutorial');
31
+ localStorage.setItem('tutorialWeekNumber', weekNumber.toString());
32
+ } catch {}
33
+ }
34
+
35
+ return (
36
+ <div className="tutorial-refinity-container" style={{ minHeight: '600px' }}>
37
+ <Refinity />
38
+ </div>
39
+ );
40
+ };
41
+
42
+ export default TutorialRefinity;
43
+
client/src/pages/TutorialTasks.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import React, { useState, useEffect, useCallback, useRef, useLayoutEffect } from 'react';
2
  import { useNavigate } from 'react-router-dom';
3
  import { api } from '../services/api';
 
4
  import {
5
  AcademicCapIcon,
6
  DocumentTextIcon,
@@ -566,6 +567,17 @@ const TutorialTasks: React.FC = () => {
566
  const [editingBrief, setEditingBrief] = useState<{[key: number]: boolean}>({});
567
  const [addingTask, setAddingTask] = useState<boolean>(false);
568
  const [addingImage, setAddingImage] = useState<boolean>(false);
 
 
 
 
 
 
 
 
 
 
 
569
  const [editForm, setEditForm] = useState<{
570
  content: string;
571
  translationBrief: string;
@@ -795,7 +807,6 @@ const TutorialTasks: React.FC = () => {
795
  const tasks = response.data;
796
  console.log('Fetched tasks:', tasks);
797
  console.log('Tasks with images:', tasks.filter((task: TutorialTask) => task.imageUrl));
798
-
799
  // Debug: Log each task's fields
800
  tasks.forEach((task: any, index: number) => {
801
  console.log(`Task ${index} fields:`, {
@@ -1401,6 +1412,25 @@ const TutorialTasks: React.FC = () => {
1401
  });
1402
  };
1403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1404
 
1405
 
1406
  const saveNewTask = async () => {
@@ -1525,14 +1555,13 @@ const TutorialTasks: React.FC = () => {
1525
 
1526
  if (response.status >= 200 && response.status < 300) {
1527
  await fetchTutorialTasks(false);
1528
-
1529
  } else {
1530
  console.error('Failed to delete tutorial task:', response.data);
1531
-
1532
  }
 
 
1533
  } catch (error) {
1534
- console.error('Failed to delete tutorial task:', error);
1535
-
1536
  }
1537
  };
1538
 
@@ -2110,6 +2139,15 @@ const TutorialTasks: React.FC = () => {
2110
  <span className="font-medium">Add Image</span>
2111
  </button>
2112
  )}
 
 
 
 
 
 
 
 
 
2113
  </div>
2114
 
2115
  </div>
@@ -2120,7 +2158,62 @@ const TutorialTasks: React.FC = () => {
2120
  )}
2121
 
2122
  <div ref={listRef} style={{ overflowAnchor: 'none' }}>
2123
- {tutorialTasks.length === 0 && !addingTask ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2124
  <div className="text-center py-12">
2125
  <DocumentTextIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
2126
  <h3 className="text-lg font-medium text-gray-900 mb-2">
@@ -2153,7 +2246,8 @@ const TutorialTasks: React.FC = () => {
2153
  <DocumentTextIcon className="relative h-5 w-5 text-ui-text" />
2154
  </div>
2155
  <div>
2156
- <h3 className="text-lg font-semibold text-gray-900 flex items-center">Source Text #{tutorialTasks.indexOf(task) + 1}
 
2157
  {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && selectedWeek >= 4 && (
2158
  <span className="ml-2 inline-flex items-center space-x-1">
2159
  <button
 
1
  import React, { useState, useEffect, useCallback, useRef, useLayoutEffect } from 'react';
2
  import { useNavigate } from 'react-router-dom';
3
  import { api } from '../services/api';
4
+ import TutorialRefinity from '../components/TutorialRefinity';
5
  import {
6
  AcademicCapIcon,
7
  DocumentTextIcon,
 
567
  const [editingBrief, setEditingBrief] = useState<{[key: number]: boolean}>({});
568
  const [addingTask, setAddingTask] = useState<boolean>(false);
569
  const [addingImage, setAddingImage] = useState<boolean>(false);
570
+ const [addingRevision, setAddingRevision] = useState<boolean>(false);
571
+ const [revisionAdded, setRevisionAdded] = useState<boolean>(() => {
572
+ const saved = localStorage.getItem(`tutorial_revision_added_week_${selectedWeek}`);
573
+ return saved === 'true';
574
+ });
575
+
576
+ // Update revisionAdded state when week changes
577
+ React.useEffect(() => {
578
+ const saved = localStorage.getItem(`tutorial_revision_added_week_${selectedWeek}`);
579
+ setRevisionAdded(saved === 'true');
580
+ }, [selectedWeek]);
581
  const [editForm, setEditForm] = useState<{
582
  content: string;
583
  translationBrief: string;
 
807
  const tasks = response.data;
808
  console.log('Fetched tasks:', tasks);
809
  console.log('Tasks with images:', tasks.filter((task: TutorialTask) => task.imageUrl));
 
810
  // Debug: Log each task's fields
811
  tasks.forEach((task: any, index: number) => {
812
  console.log(`Task ${index} fields:`, {
 
1412
  });
1413
  };
1414
 
1415
+ const startAddingRevision = () => {
1416
+ setAddingRevision(true);
1417
+ };
1418
+
1419
+ const cancelAddingRevision = () => {
1420
+ setAddingRevision(false);
1421
+ };
1422
+
1423
+ const addRevision = () => {
1424
+ setRevisionAdded(true);
1425
+ setAddingRevision(false);
1426
+ localStorage.setItem(`tutorial_revision_added_week_${selectedWeek}`, 'true');
1427
+ };
1428
+
1429
+ const removeRevision = () => {
1430
+ setRevisionAdded(false);
1431
+ localStorage.removeItem(`tutorial_revision_added_week_${selectedWeek}`);
1432
+ };
1433
+
1434
 
1435
 
1436
  const saveNewTask = async () => {
 
1555
 
1556
  if (response.status >= 200 && response.status < 300) {
1557
  await fetchTutorialTasks(false);
 
1558
  } else {
1559
  console.error('Failed to delete tutorial task:', response.data);
 
1560
  }
1561
+
1562
+ await fetchTutorialTasks(false);
1563
  } catch (error) {
1564
+ console.error('Failed to delete task:', error);
 
1565
  }
1566
  };
1567
 
 
2139
  <span className="font-medium">Add Image</span>
2140
  </button>
2141
  )}
2142
+ {selectedWeek >= 5 && (
2143
+ <button
2144
+ onClick={startAddingRevision}
2145
+ className="relative inline-flex items-center gap-2 px-4 py-2 rounded-2xl text-sm font-medium text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 shadow-[inset_0_1px_0_rgba(255,255,255,0.6),inset_0_-1px_0_rgba(0,0,0,0.12)] bg-purple-600/70"
2146
+ >
2147
+ <PlusIcon className="h-5 w-5" />
2148
+ <span className="font-medium">Add Revision</span>
2149
+ </button>
2150
+ )}
2151
  </div>
2152
 
2153
  </div>
 
2158
  )}
2159
 
2160
  <div ref={listRef} style={{ overflowAnchor: 'none' }}>
2161
+ {addingRevision ? (
2162
+ <div className="bg-white rounded-xl shadow-lg border border-gray-100 p-8 mb-8">
2163
+ <div className="flex items-center justify-between mb-4">
2164
+ <div className="flex items-center space-x-3">
2165
+ <div className="bg-purple-100 rounded-lg p-2">
2166
+ <PlusIcon className="h-5 w-5 text-purple-600" />
2167
+ </div>
2168
+ <h3 className="text-lg font-semibold text-gray-900">Add Deep Revision</h3>
2169
+ </div>
2170
+ <div className="flex items-center space-x-2">
2171
+ <button
2172
+ onClick={addRevision}
2173
+ className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
2174
+ >
2175
+ <CheckIcon className="h-4 w-4" />
2176
+ <span>Add</span>
2177
+ </button>
2178
+ <button
2179
+ onClick={cancelAddingRevision}
2180
+ className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
2181
+ >
2182
+ <XMarkIcon className="h-4 w-4" />
2183
+ <span>Cancel</span>
2184
+ </button>
2185
+ </div>
2186
+ </div>
2187
+ <div className="border-t pt-4">
2188
+ <TutorialRefinity weekNumber={selectedWeek} />
2189
+ </div>
2190
+ </div>
2191
+ ) : null}
2192
+ {revisionAdded ? (
2193
+ <div className="bg-white rounded-xl shadow-lg border border-gray-100 p-8 mb-8 max-w-full overflow-x-hidden">
2194
+ <div className="flex items-center justify-between mb-4">
2195
+ <div className="flex items-center space-x-3">
2196
+ <div className="bg-purple-100 rounded-lg p-2">
2197
+ <PlusIcon className="h-5 w-5 text-purple-600" />
2198
+ </div>
2199
+ <h3 className="text-lg font-semibold text-gray-900">Deep Revision</h3>
2200
+ </div>
2201
+ {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
2202
+ <button
2203
+ onClick={removeRevision}
2204
+ className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
2205
+ >
2206
+ <XMarkIcon className="h-4 w-4" />
2207
+ <span>Remove</span>
2208
+ </button>
2209
+ )}
2210
+ </div>
2211
+ <div className="border-t pt-4 max-w-full overflow-x-hidden">
2212
+ <TutorialRefinity weekNumber={selectedWeek} />
2213
+ </div>
2214
+ </div>
2215
+ ) : null}
2216
+ {tutorialTasks.length === 0 && !addingTask && !addingRevision && !revisionAdded ? (
2217
  <div className="text-center py-12">
2218
  <DocumentTextIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
2219
  <h3 className="text-lg font-medium text-gray-900 mb-2">
 
2246
  <DocumentTextIcon className="relative h-5 w-5 text-ui-text" />
2247
  </div>
2248
  <div>
2249
+ <h3 className="text-lg font-semibold text-gray-900 flex items-center">
2250
+ Source Text #{tutorialTasks.indexOf(task) + 1}
2251
  {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && selectedWeek >= 4 && (
2252
  <span className="ml-2 inline-flex items-center space-x-1">
2253
  <button
client/src/services/api.ts CHANGED
@@ -7,7 +7,10 @@ const envUrlNoTrailingSlash = envUrlRaw.replace(/\/$/, '');
7
  const envBaseWithoutApi = envUrlNoTrailingSlash.replace(/\/api$/, '');
8
  // 2) Prefer localhost if the app runs on localhost
9
  const isLocalhost = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname);
10
- const localCandidates = isLocalhost ? ['http://localhost:7860', 'http://127.0.0.1:7860', 'http://localhost:5000', 'http://127.0.0.1:5000'] : [];
 
 
 
11
  // 3) HF backend as final fallback
12
  const hfBackend = 'https://linguabot-transhub-backend.hf.space';
13
  // 4) Build candidate list (deduped, truthy)
@@ -57,11 +60,16 @@ async function probeBases() {
57
  }
58
  // nothing reachable - keep current activeBase (likely HF)
59
  }
60
- // Kick off probe without blocking module init
 
 
61
  if (typeof window !== 'undefined') {
62
- // initialize from stored or first candidate, then probe
63
  setActiveBase(activeBase);
64
- probeBases();
 
 
 
65
  }
66
 
67
  // Debug: Log the API URL being used
@@ -141,7 +149,7 @@ api.interceptors.response.use(
141
  }
142
  }
143
  }
144
-
145
  // Don't auto-redirect for admin operations - let the component handle it
146
  if (error.response?.status === 401 && !error.config?.url?.includes('/subtitles/update/')) {
147
  // Token expired or invalid - only redirect for non-admin operations
 
7
  const envBaseWithoutApi = envUrlNoTrailingSlash.replace(/\/api$/, '');
8
  // 2) Prefer localhost if the app runs on localhost
9
  const isLocalhost = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname);
10
+ // Prefer 5000 first when running locally; 7860 as secondary
11
+ const localCandidates = isLocalhost
12
+ ? ['http://localhost:5000', 'http://127.0.0.1:5000', 'http://localhost:7860', 'http://127.0.0.1:7860']
13
+ : [];
14
  // 3) HF backend as final fallback
15
  const hfBackend = 'https://linguabot-transhub-backend.hf.space';
16
  // 4) Build candidate list (deduped, truthy)
 
60
  }
61
  // nothing reachable - keep current activeBase (likely HF)
62
  }
63
+ // Kick off probe without blocking module init.
64
+ // In development we skip the active probing to avoid dev-tool extensions turning
65
+ // harmless network probe failures into hard errors during bundle evaluation.
66
  if (typeof window !== 'undefined') {
67
+ // initialize from stored or first candidate
68
  setActiveBase(activeBase);
69
+ // Only auto-probe in production / deployed environments
70
+ if (process.env.NODE_ENV === 'production') {
71
+ probeBases();
72
+ }
73
  }
74
 
75
  // Debug: Log the API URL being used
 
149
  }
150
  }
151
  }
152
+
153
  // Don't auto-redirect for admin operations - let the component handle it
154
  if (error.response?.status === 401 && !error.config?.url?.includes('/subtitles/update/')) {
155
  // Token expired or invalid - only redirect for non-admin operations