linguabot commited on
Commit
5a11b0a
·
verified ·
1 Parent(s): 2ad0c16

Upload folder using huggingface_hub

Browse files
client/src/components/Layout.tsx CHANGED
@@ -145,8 +145,8 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
145
 
146
  const handleLogout = () => {
147
  try {
148
- localStorage.removeItem('token');
149
- localStorage.removeItem('user');
150
  } catch {}
151
  window.location.href = '/login';
152
  };
@@ -201,9 +201,9 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
201
  <Link to="/dashboard" className="text-[1.6rem] font-bold text-ui-text flex items-center -ml-4 hover:text-ui-text" style={{ fontFamily: 'Lobster, Inter, system-ui, sans-serif' }}>
202
  <img src="/favicon-512x512.png" alt="logo" className="h-8 w-8 mr-2" />
203
  TransHub
204
- </Link>
205
  <div />
206
- </div>
207
  </header>
208
 
209
  {/* Shell: Sidebar + Content */}
@@ -211,43 +211,43 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
211
  {/* Sidebar */}
212
  <aside className="hidden md:flex md:flex-col w-60 fixed top-14 left-0 bottom-0 border-r border-ui-border bg-ui-panel/80 backdrop-blur z-30 sidebar-shell">
213
  <nav className="p-4 space-y-2 flex-1 sidebar-nav">
214
- {navigation.map((item) => {
215
- const isActive = location.pathname === item.href;
216
- return (
217
- <Link
218
- key={item.name}
219
- to={item.href}
220
  className={`flex items-center px-3 py-2 rounded-lg text-[0.95rem] font-medium transition-colors ${
221
  isActive ? 'text-ui-text' : 'text-ui-text/80 hover:text-ui-text'
222
- }`}
223
- >
224
  <img src={iconSrcFor(item.name)} alt="" className="h-6 w-6 mr-3" />
225
  <span>{item.name}</span>
226
- </Link>
227
- );
228
- })}
229
  </nav>
230
  <div className="p-3 border-t border-ui-border mt-auto sidebar-footer">
231
  {user ? (
232
  <button onClick={handleLogout} className="w-full flex items-center justify-start px-3 py-2 rounded-md text-sm font-medium text-ui-text/80 hover:bg-ui-panel/60">
233
  <PowerIcon className="h-4 w-4 mr-2" />
234
  Log Out
235
- </button>
236
  ) : (
237
  <Link to="/login" className="w-full flex items-center justify-start px-3 py-2 rounded-md text-sm font-medium text-ui-text/80 hover:bg-ui-panel/60">
238
  <PowerIcon className="h-4 w-4 mr-2" />
239
  Log In
240
  </Link>
241
  )}
242
- </div>
243
  </aside>
244
 
245
- {/* Main Content */}
246
  <main className="flex-1 p-4 sm:p-6 lg:p-8 md:ml-60">
247
- {!isTransitioning && children}
248
- </main>
249
  </div>
250
-
251
  {/* Transition Loading Indicator */}
252
  {isTransitioning && (
253
  <div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50">
 
145
 
146
  const handleLogout = () => {
147
  try {
148
+ localStorage.removeItem('token');
149
+ localStorage.removeItem('user');
150
  } catch {}
151
  window.location.href = '/login';
152
  };
 
201
  <Link to="/dashboard" className="text-[1.6rem] font-bold text-ui-text flex items-center -ml-4 hover:text-ui-text" style={{ fontFamily: 'Lobster, Inter, system-ui, sans-serif' }}>
202
  <img src="/favicon-512x512.png" alt="logo" className="h-8 w-8 mr-2" />
203
  TransHub
204
+ </Link>
205
  <div />
206
+ </div>
207
  </header>
208
 
209
  {/* Shell: Sidebar + Content */}
 
211
  {/* Sidebar */}
212
  <aside className="hidden md:flex md:flex-col w-60 fixed top-14 left-0 bottom-0 border-r border-ui-border bg-ui-panel/80 backdrop-blur z-30 sidebar-shell">
213
  <nav className="p-4 space-y-2 flex-1 sidebar-nav">
214
+ {navigation.map((item) => {
215
+ const isActive = location.pathname === item.href;
216
+ return (
217
+ <Link
218
+ key={item.name}
219
+ to={item.href}
220
  className={`flex items-center px-3 py-2 rounded-lg text-[0.95rem] font-medium transition-colors ${
221
  isActive ? 'text-ui-text' : 'text-ui-text/80 hover:text-ui-text'
222
+ }`}
223
+ >
224
  <img src={iconSrcFor(item.name)} alt="" className="h-6 w-6 mr-3" />
225
  <span>{item.name}</span>
226
+ </Link>
227
+ );
228
+ })}
229
  </nav>
230
  <div className="p-3 border-t border-ui-border mt-auto sidebar-footer">
231
  {user ? (
232
  <button onClick={handleLogout} className="w-full flex items-center justify-start px-3 py-2 rounded-md text-sm font-medium text-ui-text/80 hover:bg-ui-panel/60">
233
  <PowerIcon className="h-4 w-4 mr-2" />
234
  Log Out
235
+ </button>
236
  ) : (
237
  <Link to="/login" className="w-full flex items-center justify-start px-3 py-2 rounded-md text-sm font-medium text-ui-text/80 hover:bg-ui-panel/60">
238
  <PowerIcon className="h-4 w-4 mr-2" />
239
  Log In
240
  </Link>
241
  )}
242
+ </div>
243
  </aside>
244
 
245
+ {/* Main Content */}
246
  <main className="flex-1 p-4 sm:p-6 lg:p-8 md:ml-60">
247
+ {!isTransitioning && children}
248
+ </main>
249
  </div>
250
+
251
  {/* Transition Loading Indicator */}
252
  {isTransitioning && (
253
  <div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50">
client/src/components/Refinity.tsx CHANGED
@@ -22,6 +22,28 @@ type Version = {
22
  parentVersionId?: string; // lineage
23
  };
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  type Task = {
26
  id: string;
27
  title: string;
@@ -53,6 +75,13 @@ const Refinity: React.FC = () => {
53
  const [compareMenuPos, setCompareMenuPos] = React.useState<{ left: number; top: number } | null>(null);
54
  const compareBtnRef = React.useRef<HTMLButtonElement | null>(null);
55
  const [revDownloadOpen, setRevDownloadOpen] = React.useState<boolean>(false);
 
 
 
 
 
 
 
56
  const [username] = React.useState<string>(() => {
57
  try {
58
  const u = localStorage.getItem('user');
@@ -233,6 +262,98 @@ const Refinity: React.FC = () => {
233
  }
234
  };
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  // Flow slide text overflow measurement for multi-line ellipsis indicator
237
  const textRefs = React.useRef<{ [id: string]: HTMLDivElement | null }>({});
238
  const [overflowMap, setOverflowMap] = React.useState<{ [id: string]: boolean }>({});
@@ -406,6 +527,84 @@ const Refinity: React.FC = () => {
406
  <button onClick={() => setIsFullscreen(false)} className="px-3 py-1.5 text-sm rounded-2xl border border-gray-200 bg-white">Exit Full Screen</button>
407
  </div>
408
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  {/* Clean container (no purple gradient) */}
410
  <div className={isFullscreen ? 'relative mx-auto max-w-6xl p-6' : 'relative rounded-xl p-6 bg-transparent'}>
411
  {/* Stage 1: Task Creation / Selection */}
@@ -627,9 +826,65 @@ const Refinity: React.FC = () => {
627
  ref={(el)=>{ textRefs.current[v.id]=el; }}
628
  className={`text-gray-900 whitespace-pre-wrap break-words leading-relaxed flex-1 overflow-hidden pr-1 relative`}
629
  style={{ maxHeight: 'calc(100% - 10.5rem)', paddingBottom: '0.25rem' }}
630
- >
631
- {v.content || ''}
632
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  {overflowMap[v.id] && (
634
  <div className="pointer-events-none absolute text-gray-700" style={{ left: '1.5rem', bottom: '6rem', zIndex: 2000 }}>…</div>
635
  )}
@@ -914,6 +1169,7 @@ const Refinity: React.FC = () => {
914
  nextVersionNumber={((versions.filter(v=>v.taskId===(task?.id||'')).slice(-1)[0]?.versionNumber) || 0) + 1}
915
  isFullscreen={isFullscreen}
916
  onToggleFullscreen={()=>setIsFullscreen(v=>!v)}
 
917
  />
918
  )}
919
  </div>
@@ -921,7 +1177,7 @@ const Refinity: React.FC = () => {
921
  );
922
  };
923
 
924
- 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 }>=({ source, initialTranslation, onBack, onSave, onSaveEdit, taskTitle, username, nextVersionNumber, isFullscreen, onToggleFullscreen })=>{
925
  const [text, setText] = React.useState<string>(initialTranslation);
926
  const [saving, setSaving] = React.useState(false);
927
  const [diffHtml, setDiffHtml] = React.useState<string>('');
@@ -934,6 +1190,132 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
934
  const [commentsOpen, setCommentsOpen] = React.useState(false);
935
  const [newComment, setNewComment] = React.useState('');
936
  const [comments, setComments] = React.useState<Array<{ id: string; text: string; ts: number }>>([]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
937
 
938
  React.useEffect(() => {
939
  if (sourceRef.current) {
@@ -1026,48 +1408,94 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1026
  <div className="w-1/2">
1027
  <div className="mb-2 text-gray-700 text-sm flex items-center justify-between">
1028
  <span>Translation</span>
1029
- {!isFullscreen && (
1030
- <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">
1031
- Full Screen
1032
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1033
  )}
1034
  </div>
1035
- <textarea
1036
- value={text}
1037
- onChange={(e)=>setText(e.target.value)}
1038
- className="relative z-10 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"
1039
- style={{
1040
- minHeight: textareaHeight,
1041
- height: textareaHeight,
1042
- maxHeight: textareaHeight,
1043
- resize: 'none',
1044
- overflowY: 'auto'
1045
- }}
1046
- />
1047
  <div className="mt-4 flex gap-3 relative">
1048
  <button onClick={save} disabled={saving} 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 disabled:bg-gray-400 active:translate-y-0.5 transition-all duration-200">{saving? 'Saving…':'Save'}</button>
1049
  <div className="relative inline-block align-top">
1050
- <button ref={revBtnRef} onClick={async (e)=>{ e.preventDefault(); e.stopPropagation(); try { const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const filename=`${(taskTitle||'Task').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_${(username||'User').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}.docx`; const body={ current: text||'', filename }; const 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(); const url=window.URL.createObjectURL(blob); const link=document.createElement('a'); link.href=url; link.download=filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);} catch {} }} 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">Download</button>
 
 
 
 
 
 
1051
  </div>
1052
- <button onClick={()=>setCommentsOpen(o=>!o)} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium rounded-2xl text-black ring-1 ring-inset ring-white/50 backdrop-blur-md bg-white/30 hover:bg-white/50 transition-all duration-200">Comments</button>
1053
  <button onClick={onBack} className="ml-auto 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">Back</button>
1054
  </div>
1055
- {commentsOpen && (
1056
- <div className="mt-3 rounded-xl border border-gray-200 bg-white/60 p-3">
1057
- <div className="text-sm text-gray-800 mb-2">Add a brief comment (optional)</div>
1058
- <div className="flex gap-2">
1059
- <input value={newComment} onChange={(e)=>setNewComment(e.target.value)} className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white text-sm" placeholder="Your comment…" />
1060
- <button onClick={()=>{ const t=newComment.trim(); if(t){ setComments(prev=>[{ id: String(Date.now()), text: t, ts: Date.now() }, ...prev]); setNewComment(''); } }} className="px-3 py-2 text-xs rounded-xl bg-indigo-600/70 text-white hover:bg-indigo-700">Add</button>
1061
- </div>
1062
- {comments.length>0 && (
1063
- <div className="mt-3 space-y-2 max-h-40 overflow-auto">
1064
- {comments.map(c=>(
1065
- <div key={c.id} className="text-xs text-gray-700 bg-white rounded-lg border border-gray-200 p-2">{c.text}</div>
1066
- ))}
1067
- </div>
1068
- )}
1069
- </div>
1070
- )}
1071
  {showDiff && (
1072
  <div className="mt-6 relative rounded-xl">
1073
  <div className="absolute inset-0 rounded-xl bg-gradient-to-r from-indigo-200/45 via-indigo-100/40 to-indigo-300/45" />
@@ -1080,6 +1508,63 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
1080
  )}
1081
  </div>
1082
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1083
  </div>
1084
  );
1085
  };
 
22
  parentVersionId?: string; // lineage
23
  };
24
 
25
+ type AnnotationCategory =
26
+ | 'distortion'
27
+ | 'omission'
28
+ | 'register'
29
+ | 'unidiomatic'
30
+ | 'grammar'
31
+ | 'spelling'
32
+ | 'punctuation'
33
+ | 'addition'
34
+ | 'other';
35
+
36
+ type Annotation = {
37
+ id: string;
38
+ versionId: string;
39
+ start: number; // inclusive, char index in version.content
40
+ end: number; // exclusive
41
+ category: AnnotationCategory;
42
+ comment?: string;
43
+ createdAt: number;
44
+ updatedAt: number;
45
+ };
46
+
47
  type Task = {
48
  id: string;
49
  title: string;
 
75
  const [compareMenuPos, setCompareMenuPos] = React.useState<{ left: number; top: number } | null>(null);
76
  const compareBtnRef = React.useRef<HTMLButtonElement | null>(null);
77
  const [revDownloadOpen, setRevDownloadOpen] = React.useState<boolean>(false);
78
+ // Annotations (Version Flow)
79
+ const [showAnnotations, setShowAnnotations] = React.useState<boolean>(true);
80
+ const [annotationPopover, setAnnotationPopover] = React.useState<{ left: number; top: number; versionId: string; range: { start: number; end: number } } | null>(null);
81
+ const [annotationModalOpen, setAnnotationModalOpen] = React.useState<boolean>(false);
82
+ const [editingAnnotation, setEditingAnnotation] = React.useState<Annotation | null>(null);
83
+ const [modalCategory, setModalCategory] = React.useState<AnnotationCategory>('distortion');
84
+ const [modalComment, setModalComment] = React.useState<string>('');
85
  const [username] = React.useState<string>(() => {
86
  try {
87
  const u = localStorage.getItem('user');
 
262
  }
263
  };
264
 
265
+ // ---------- Annotation helpers ----------
266
+ const ANNO_STORE_KEY = 'refinity_annotations_v1';
267
+ const loadAnnotations = React.useCallback((): Annotation[] => {
268
+ try {
269
+ const raw = localStorage.getItem(ANNO_STORE_KEY);
270
+ const arr = raw ? JSON.parse(raw) : [];
271
+ return Array.isArray(arr) ? arr : [];
272
+ } catch { return []; }
273
+ }, []);
274
+ const saveAnnotations = React.useCallback((list: Annotation[]) => {
275
+ try { localStorage.setItem(ANNO_STORE_KEY, JSON.stringify(list)); } catch {}
276
+ }, []);
277
+ const [annotations, setAnnotations] = React.useState<Annotation[]>(() => loadAnnotations());
278
+ React.useEffect(() => { saveAnnotations(annotations); }, [annotations, saveAnnotations]);
279
+ const annoForVersion = React.useCallback((versionId: string) => annotations.filter(a => a.versionId === versionId), [annotations]);
280
+ const addAnnotation = React.useCallback((a: Annotation) => setAnnotations(prev => [...prev, a]), []);
281
+ const updateAnnotation = React.useCallback((a: Annotation) => setAnnotations(prev => prev.map(x => x.id === a.id ? a : x)), []);
282
+ const deleteAnnotationById = React.useCallback((id: string) => setAnnotations(prev => prev.filter(a => a.id !== id)), []);
283
+
284
+ const CATEGORY_LABELS: Record<AnnotationCategory, string> = {
285
+ distortion: 'Distortion',
286
+ omission: 'Unjustified omission',
287
+ register: 'Inappropriate register',
288
+ unidiomatic: 'Unidiomatic expression',
289
+ grammar: 'Error of grammar, syntax',
290
+ spelling: 'Error of spelling',
291
+ punctuation: 'Error of punctuation',
292
+ addition: 'Unjustified addition',
293
+ other: 'Other',
294
+ };
295
+ const CATEGORY_CLASS: Record<AnnotationCategory, string> = {
296
+ distortion: 'bg-rose-100',
297
+ omission: 'bg-amber-100',
298
+ register: 'bg-indigo-100',
299
+ unidiomatic: 'bg-purple-100',
300
+ grammar: 'bg-blue-100',
301
+ spelling: 'bg-green-100',
302
+ punctuation: 'bg-teal-100',
303
+ addition: 'bg-pink-100',
304
+ other: 'bg-gray-100',
305
+ };
306
+ function escapeHtml(s: string): string {
307
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
308
+ }
309
+ function renderAnnotatedHtml(text: string, versionId: string, enabled: boolean): string {
310
+ if (!enabled) return escapeHtml(text);
311
+ const list = annoForVersion(versionId).slice().sort((a,b)=>a.start-b.start);
312
+ if (!list.length) return escapeHtml(text);
313
+ let html = '';
314
+ let pos = 0;
315
+ for (const a of list) {
316
+ const start = Math.max(0, Math.min(a.start, text.length));
317
+ const end = Math.max(start, Math.min(a.end, text.length));
318
+ if (pos < start) html += escapeHtml(text.slice(pos, start));
319
+ const label = CATEGORY_LABELS[a.category] || 'Note';
320
+ const cls = CATEGORY_CLASS[a.category] || 'bg-gray-100 ring-gray-200';
321
+ const span = escapeHtml(text.slice(start, end)) || '&nbsp;';
322
+ html += `<span data-anno-id="${a.id}" class="inline rounded-[6px] ring-1 ${cls} px-0.5" title="${label}${a.comment ? ': '+escapeHtml(a.comment) : ''}">${span}</span>`;
323
+ pos = end;
324
+ }
325
+ if (pos < text.length) html += escapeHtml(text.slice(pos));
326
+ return html;
327
+ }
328
+ function rangeToOffsets(container: HTMLElement, range: Range, fullText: string): { start: number; end: number } {
329
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
330
+ let start = -1;
331
+ let end = -1;
332
+ let charCount = 0;
333
+ while (walker.nextNode()) {
334
+ const node = walker.currentNode as Text;
335
+ const text = node.nodeValue || '';
336
+ if (node === range.startContainer) start = charCount + range.startOffset;
337
+ if (node === range.endContainer) end = charCount + range.endOffset;
338
+ charCount += text.length;
339
+ }
340
+ if (start < 0 || end < 0) {
341
+ const sel = range.toString();
342
+ const idx = fullText.indexOf(sel);
343
+ if (idx >= 0) { start = idx; end = idx + sel.length; }
344
+ }
345
+ start = Math.max(0, Math.min(start, fullText.length));
346
+ end = Math.max(start, Math.min(end, fullText.length));
347
+ return { start, end };
348
+ }
349
+ function getSelectionPopoverPosition(range: Range, container: HTMLElement): { left: number; top: number } {
350
+ const rect = range.getBoundingClientRect();
351
+ const crect = container.getBoundingClientRect();
352
+ const left = Math.max(8, rect.right - crect.left + 8);
353
+ const top = Math.max(8, rect.top - crect.top - 8);
354
+ return { left, top };
355
+ }
356
+
357
  // Flow slide text overflow measurement for multi-line ellipsis indicator
358
  const textRefs = React.useRef<{ [id: string]: HTMLDivElement | null }>({});
359
  const [overflowMap, setOverflowMap] = React.useState<{ [id: string]: boolean }>({});
 
527
  <button onClick={() => setIsFullscreen(false)} className="px-3 py-1.5 text-sm rounded-2xl border border-gray-200 bg-white">Exit Full Screen</button>
528
  </div>
529
  )}
530
+ {/* Annotation modal (Version Flow only) */}
531
+ {annotationModalOpen && annotationPopover && (
532
+ <div className="fixed inset-0 z-[5000] flex items-center justify-center bg-black/40">
533
+ <div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
534
+ <h3 className="text-lg font-semibold text-gray-900 mb-4">{editingAnnotation ? 'Edit annotation' : 'New annotation'}</h3>
535
+ <div className="space-y-4">
536
+ <div>
537
+ <label className="block text-sm text-gray-700 mb-1">Category <span className="text-red-500">*</span></label>
538
+ <select
539
+ value={modalCategory}
540
+ onChange={(e)=>setModalCategory(e.target.value as AnnotationCategory)}
541
+ className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm"
542
+ >
543
+ {(['distortion','omission','register','unidiomatic','grammar','spelling','punctuation','addition','other'] as AnnotationCategory[]).map(k=>(
544
+ <option key={k} value={k}>{k==='unidiomatic' ? 'Unidiomatic expression' : k==='grammar' ? 'Error of grammar, syntax' : k==='spelling' ? 'Error of spelling' : k==='punctuation' ? 'Error of punctuation' : k==='addition' ? 'Unjustified addition' : k==='omission' ? 'Unjustified omission' : k.charAt(0).toUpperCase()+k.slice(1)}</option>
545
+ ))}
546
+ </select>
547
+ </div>
548
+ <div>
549
+ <label className="block text-sm text-gray-700 mb-1">Comment (optional)</label>
550
+ <textarea
551
+ value={modalComment}
552
+ onChange={(e)=>setModalComment(e.target.value)}
553
+ rows={3}
554
+ className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm"
555
+ placeholder="Enter the correction or context…"
556
+ />
557
+ </div>
558
+ </div>
559
+ <div className="mt-6 flex items-center justify-between">
560
+ {editingAnnotation && (
561
+ <button
562
+ onClick={()=>{
563
+ if (editingAnnotation) deleteAnnotationById(editingAnnotation.id);
564
+ setAnnotationModalOpen(false);
565
+ setAnnotationPopover(null);
566
+ setEditingAnnotation(null);
567
+ }}
568
+ className="px-3 py-1.5 text-sm rounded-lg text-white bg-red-600 hover:bg-red-700"
569
+ >
570
+ Delete
571
+ </button>
572
+ )}
573
+ <div className="ml-auto flex gap-2">
574
+ <button onClick={()=>{ setAnnotationModalOpen(false); setAnnotationPopover(null); setEditingAnnotation(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>
575
+ <button
576
+ onClick={()=>{
577
+ const versionId = annotationPopover.versionId;
578
+ if (editingAnnotation) {
579
+ const updated: Annotation = { ...editingAnnotation, category: modalCategory, comment: modalComment, updatedAt: Date.now() };
580
+ updateAnnotation(updated);
581
+ } else {
582
+ const range = annotationPopover.range;
583
+ const a: Annotation = {
584
+ id: `a_${Date.now()}_${Math.random().toString(36).slice(2,7)}`,
585
+ versionId,
586
+ start: range.start,
587
+ end: range.end,
588
+ category: modalCategory,
589
+ comment: modalComment,
590
+ createdAt: Date.now(),
591
+ updatedAt: Date.now(),
592
+ };
593
+ addAnnotation(a);
594
+ }
595
+ setAnnotationModalOpen(false);
596
+ setAnnotationPopover(null);
597
+ setEditingAnnotation(null);
598
+ }}
599
+ className="px-3 py-1.5 text-sm rounded-lg text-white bg-indigo-600 hover:bg-indigo-700"
600
+ >
601
+ Save
602
+ </button>
603
+ </div>
604
+ </div>
605
+ </div>
606
+ </div>
607
+ )}
608
  {/* Clean container (no purple gradient) */}
609
  <div className={isFullscreen ? 'relative mx-auto max-w-6xl p-6' : 'relative rounded-xl p-6 bg-transparent'}>
610
  {/* Stage 1: Task Creation / Selection */}
 
826
  ref={(el)=>{ textRefs.current[v.id]=el; }}
827
  className={`text-gray-900 whitespace-pre-wrap break-words leading-relaxed flex-1 overflow-hidden pr-1 relative`}
828
  style={{ maxHeight: 'calc(100% - 10.5rem)', paddingBottom: '0.25rem' }}
829
+ onMouseUp={(e)=>{
830
+ if (!showAnnotations) return;
831
+ const el = textRefs.current[v.id];
832
+ if (!el) return;
833
+ const sel = window.getSelection();
834
+ if (!sel || sel.rangeCount===0) return;
835
+ const range = sel.getRangeAt(0);
836
+ if (!el.contains(range.startContainer) || !el.contains(range.endContainer)) return;
837
+ const text = v.content || '';
838
+ const offsets = rangeToOffsets(el, range, text);
839
+ if (offsets.end - offsets.start <= 0) {
840
+ // caret: open if inside an existing annotation
841
+ const inside = (annoForVersion(v.id) || []).find(a => offsets.start >= a.start && offsets.start <= a.end);
842
+ if (inside) {
843
+ setEditingAnnotation(inside);
844
+ setModalCategory(inside.category);
845
+ setModalComment(inside.comment || '');
846
+ setAnnotationModalOpen(true);
847
+ }
848
+ setAnnotationPopover(null);
849
+ return;
850
+ }
851
+ const pos = getSelectionPopoverPosition(range, el);
852
+ setAnnotationPopover({ left: pos.left, top: pos.top, versionId: v.id, range: offsets });
853
+ }}
854
+ onClick={(e)=>{
855
+ if (!showAnnotations) return;
856
+ const target = e.target as HTMLElement;
857
+ const id = target?.dataset?.annoId;
858
+ if (id) {
859
+ e.preventDefault(); e.stopPropagation();
860
+ const found = (annoForVersion(v.id) || []).find(a => a.id === id);
861
+ if (found) {
862
+ setEditingAnnotation(found);
863
+ setModalCategory(found.category);
864
+ setModalComment(found.comment || '');
865
+ setAnnotationModalOpen(true);
866
+ }
867
+ }
868
+ }}
869
+ dangerouslySetInnerHTML={{ __html: renderAnnotatedHtml(v.content || '', v.id, showAnnotations) }}
870
+ />
871
+ {annotationPopover && annotationPopover.versionId===v.id && (
872
+ <div className="absolute z-[3500]" style={{ left: annotationPopover.left, top: annotationPopover.top }}>
873
+ <button
874
+ type="button"
875
+ onClick={(e)=>{
876
+ e.preventDefault(); e.stopPropagation();
877
+ setEditingAnnotation(null);
878
+ setModalCategory('distortion');
879
+ setModalComment('');
880
+ setAnnotationModalOpen(true);
881
+ }}
882
+ className="inline-flex items-center gap-2 px-2.5 py-1.5 text-xs rounded-full text-white bg-emerald-600 hover:bg-emerald-700 shadow ring-1 ring-white/50"
883
+ >
884
+ + Comment
885
+ </button>
886
+ </div>
887
+ )}
888
  {overflowMap[v.id] && (
889
  <div className="pointer-events-none absolute text-gray-700" style={{ left: '1.5rem', bottom: '6rem', zIndex: 2000 }}>…</div>
890
  )}
 
1169
  nextVersionNumber={((versions.filter(v=>v.taskId===(task?.id||'')).slice(-1)[0]?.versionNumber) || 0) + 1}
1170
  isFullscreen={isFullscreen}
1171
  onToggleFullscreen={()=>setIsFullscreen(v=>!v)}
1172
+ versionId={currentVersionId || ''}
1173
  />
1174
  )}
1175
  </div>
 
1177
  );
1178
  };
1179
 
1180
+ 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 })=>{
1181
  const [text, setText] = React.useState<string>(initialTranslation);
1182
  const [saving, setSaving] = React.useState(false);
1183
  const [diffHtml, setDiffHtml] = React.useState<string>('');
 
1190
  const [commentsOpen, setCommentsOpen] = React.useState(false);
1191
  const [newComment, setNewComment] = React.useState('');
1192
  const [comments, setComments] = React.useState<Array<{ id: string; text: string; ts: number }>>([]);
1193
+ const taRef = React.useRef<HTMLTextAreaElement | null>(null);
1194
+ const overlayRef = React.useRef<HTMLDivElement | null>(null);
1195
+ const wrapperRef = React.useRef<HTMLDivElement | null>(null);
1196
+ // Annotation layer state (revision mode only)
1197
+ const [showAnnotations, setShowAnnotations] = React.useState<boolean>(true);
1198
+ const ANNO_STORE_KEY = 'refinity_annotations_v1';
1199
+ type Ann = Annotation;
1200
+ const loadAnnotations = React.useCallback((): Ann[] => {
1201
+ try { const raw = localStorage.getItem(ANNO_STORE_KEY); const arr = raw ? JSON.parse(raw) : []; return Array.isArray(arr) ? arr : []; } catch { return []; }
1202
+ }, []);
1203
+ const saveAnnotations = React.useCallback((list: Ann[]) => { try { localStorage.setItem(ANNO_STORE_KEY, JSON.stringify(list)); } catch {} }, []);
1204
+ const [annotations, setAnnotations] = React.useState<Ann[]>(() => loadAnnotations());
1205
+ React.useEffect(() => { saveAnnotations(annotations); }, [annotations, saveAnnotations]);
1206
+ const versionAnnotations = React.useMemo(() => annotations.filter(a => a.versionId === versionId), [annotations, versionId]);
1207
+ const addAnnotation = (a: Ann) => setAnnotations(prev => [...prev, a]);
1208
+ const updateAnnotation = (a: Ann) => setAnnotations(prev => prev.map(x => x.id === a.id ? a : x));
1209
+ const deleteAnnotationById = (id: string) => setAnnotations(prev => prev.filter(a => a.id !== id));
1210
+ const CATEGORY_CLASS: Record<AnnotationCategory, string> = {
1211
+ distortion: 'bg-rose-100 ring-rose-200',
1212
+ omission: 'bg-amber-100 ring-amber-200',
1213
+ register: 'bg-indigo-100 ring-indigo-200',
1214
+ unidiomatic: 'bg-purple-100 ring-purple-200',
1215
+ grammar: 'bg-blue-100 ring-blue-200',
1216
+ spelling: 'bg-green-100 ring-green-200',
1217
+ punctuation: 'bg-teal-100 ring-teal-200',
1218
+ addition: 'bg-pink-100 ring-pink-200',
1219
+ other: 'bg-gray-100 ring-gray-200',
1220
+ };
1221
+ const [modalOpen, setModalOpen] = React.useState(false);
1222
+ const [editingAnn, setEditingAnn] = React.useState<Ann | null>(null);
1223
+ const [modalCategory, setModalCategory] = React.useState<AnnotationCategory>('distortion');
1224
+ const [modalComment, setModalComment] = React.useState('');
1225
+ const [popover, setPopover] = React.useState<{ left: number; top: number; start: number; end: number } | null>(null);
1226
+
1227
+ function escapeHtml(s: string): string {
1228
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
1229
+ }
1230
+ // Load persisted annotations for this version from backend
1231
+ React.useEffect(() => {
1232
+ (async () => {
1233
+ if (!versionId) return;
1234
+ try {
1235
+ const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
1236
+ const resp = await fetch(`${base}/api/refinity/annotations?versionId=${encodeURIComponent(versionId)}`);
1237
+ const rows = await resp.json().catch(()=>[]);
1238
+ if (Array.isArray(rows)) {
1239
+ setAnnotations(prev => {
1240
+ const others = prev.filter(a => a.versionId !== versionId);
1241
+ 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 }));
1242
+ return [...others, ...loaded];
1243
+ });
1244
+ }
1245
+ } catch {}
1246
+ })();
1247
+ }, [versionId]);
1248
+
1249
+ // Persistence helpers
1250
+ const persistCreate = React.useCallback(async (a: Ann): Promise<Ann> => {
1251
+ try {
1252
+ const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
1253
+ const body = { versionId: a.versionId, start: a.start, end: a.end, category: a.category, comment: a.comment };
1254
+ const resp = await fetch(`${base}/api/refinity/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
1255
+ const row = await resp.json().catch(()=>({}));
1256
+ if (resp.ok && row && row._id) return { ...a, id: row._id };
1257
+ } catch {}
1258
+ return a;
1259
+ }, []);
1260
+ const persistUpdate = React.useCallback(async (a: Ann) => {
1261
+ try {
1262
+ const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
1263
+ const body = { start: a.start, end: a.end, category: a.category, comment: a.comment };
1264
+ await fetch(`${base}/api/refinity/annotations/${encodeURIComponent(a.id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
1265
+ } catch {}
1266
+ }, []);
1267
+ const persistDelete = React.useCallback(async (id: string) => {
1268
+ try {
1269
+ const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
1270
+ await fetch(`${base}/api/refinity/annotations/${encodeURIComponent(id)}`, { method: 'DELETE' });
1271
+ } catch {}
1272
+ }, []);
1273
+ function renderAnnotatedHtml(textValue: string): string {
1274
+ if (!showAnnotations) return escapeHtml(textValue);
1275
+ const list = versionAnnotations.slice().sort((a,b)=>a.start-b.start);
1276
+ if (!list.length) return escapeHtml(textValue);
1277
+ let html = ''; let pos = 0;
1278
+ for (const a of list) {
1279
+ const start = Math.max(0, Math.min(a.start, textValue.length));
1280
+ const end = Math.max(start, Math.min(a.end, textValue.length));
1281
+ if (pos < start) html += escapeHtml(textValue.slice(pos, start));
1282
+ const cls = CATEGORY_CLASS[a.category] || 'bg-gray-100';
1283
+ const span = escapeHtml(textValue.slice(start, end)) || '&nbsp;';
1284
+ html += `<span data-anno-id="${a.id}" class="inline rounded-[4px] ${cls} pointer-events-auto cursor-pointer" title="${a.category}${a.comment ? ': '+escapeHtml(a.comment) : ''}">${span}</span>`;
1285
+ pos = end;
1286
+ }
1287
+ if (pos < textValue.length) html += escapeHtml(textValue.slice(pos));
1288
+ return html;
1289
+ }
1290
+ function offsetsToClientRect(container: HTMLElement, start: number, end: number): DOMRect | null {
1291
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
1292
+ let charCount = 0;
1293
+ let startNode: Text | null = null, endNode: Text | null = null;
1294
+ let startOffset = 0, endOffset = 0;
1295
+ while (walker.nextNode()) {
1296
+ const node = walker.currentNode as Text;
1297
+ const len = (node.nodeValue || '').length;
1298
+ if (!startNode && charCount + len >= start) { startNode = node; startOffset = start - charCount; }
1299
+ if (!endNode && charCount + len >= end) { endNode = node; endOffset = end - charCount; break; }
1300
+ charCount += len;
1301
+ }
1302
+ if (!startNode || !endNode) return null;
1303
+ const r = document.createRange();
1304
+ r.setStart(startNode, Math.max(0, Math.min(startOffset, (startNode.nodeValue||'').length)));
1305
+ r.setEnd(endNode, Math.max(0, Math.min(endOffset, (endNode.nodeValue||'').length)));
1306
+ const rect = r.getBoundingClientRect();
1307
+ return rect;
1308
+ }
1309
+ const updateSelectionPopover = React.useCallback(() => {
1310
+ const ta = taRef.current, ov = overlayRef.current, wrap = wrapperRef.current;
1311
+ if (!ta || !ov || !wrap) { setPopover(null); return; }
1312
+ const s = ta.selectionStart ?? 0, e = ta.selectionEnd ?? 0;
1313
+ if (e - s <= 0) { setPopover(null); return; }
1314
+ const rect = offsetsToClientRect(ov, s, e);
1315
+ if (!rect) { setPopover(null); return; }
1316
+ const wrect = wrap.getBoundingClientRect();
1317
+ setPopover({ left: Math.max(8, rect.right - wrect.left + 8), top: Math.max(8, rect.top - wrect.top - 8), start: s, end: e });
1318
+ }, [showAnnotations]);
1319
 
1320
  React.useEffect(() => {
1321
  if (sourceRef.current) {
 
1408
  <div className="w-1/2">
1409
  <div className="mb-2 text-gray-700 text-sm flex items-center justify-between">
1410
  <span>Translation</span>
1411
+ <div className="flex items-center gap-2">
1412
+ <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">
1413
+ <input type="checkbox" checked={showAnnotations} onChange={(e)=>{ setShowAnnotations(e.target.checked); setPopover(null); }} />
1414
+ <span>Show comments</span>
1415
+ </label>
1416
+ {!isFullscreen && (
1417
+ <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">
1418
+ Full Screen
1419
+ </button>
1420
+ )}
1421
+ </div>
1422
+ </div>
1423
+ <div ref={wrapperRef} className="relative overflow-hidden">
1424
+ {/* Overlay highlights rendered above, but non-interactive; textarea handles selection */}
1425
+ <div
1426
+ ref={overlayRef}
1427
+ className="pointer-events-none absolute inset-0 rounded-lg px-4 py-3 whitespace-pre-wrap text-gray-900 overflow-hidden z-0 text-base leading-relaxed will-change-transform"
1428
+ style={{ minHeight: textareaHeight, height: textareaHeight, maxHeight: textareaHeight, transform: 'translateY(0px)' }}
1429
+ dangerouslySetInnerHTML={{ __html: renderAnnotatedHtml(text) }}
1430
+ />
1431
+ <textarea
1432
+ ref={taRef}
1433
+ value={text}
1434
+ onChange={(e)=>setText(e.target.value)}
1435
+ onMouseUp={()=>{
1436
+ updateSelectionPopover();
1437
+ // If caret inside an existing annotation, open edit modal
1438
+ const s = taRef.current?.selectionStart ?? 0;
1439
+ const e = taRef.current?.selectionEnd ?? 0;
1440
+ if (s === e) {
1441
+ const hit = versionAnnotations.find(a => s >= a.start && s <= a.end);
1442
+ if (hit) {
1443
+ setEditingAnn(hit);
1444
+ setModalCategory(hit.category);
1445
+ setModalComment(hit.comment || '');
1446
+ setModalOpen(true);
1447
+ }
1448
+ }
1449
+ }}
1450
+ onScroll={(e)=>{ const el = e.currentTarget; if (overlayRef.current) { overlayRef.current.style.transform = `translateY(-${el.scrollTop}px)`; } }}
1451
+ onKeyUp={updateSelectionPopover}
1452
+ className="relative z-10 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"
1453
+ style={{
1454
+ minHeight: textareaHeight,
1455
+ height: textareaHeight,
1456
+ maxHeight: textareaHeight,
1457
+ resize: 'none',
1458
+ overflowY: 'auto',
1459
+ background: 'transparent',
1460
+ color: 'transparent' as any,
1461
+ caretColor: '#111827'
1462
+ }}
1463
+ />
1464
+ {/* + Comment popover */}
1465
+ {popover && (
1466
+ <div className="absolute z-20" style={{ left: popover.left, top: popover.top }}>
1467
+ <button
1468
+ type="button"
1469
+ onClick={()=>{
1470
+ setEditingAnn(null);
1471
+ setModalCategory('distortion');
1472
+ setModalComment('');
1473
+ setModalOpen(true);
1474
+ }}
1475
+ 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"
1476
+ >
1477
+ <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%)]" />
1478
+ <div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
1479
+ <span className="relative z-10">+ Comment</span>
1480
+ </button>
1481
+ </div>
1482
  )}
1483
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
1484
  <div className="mt-4 flex gap-3 relative">
1485
  <button onClick={save} disabled={saving} 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 disabled:bg-gray-400 active:translate-y-0.5 transition-all duration-200">{saving? 'Saving…':'Save'}</button>
1486
  <div className="relative inline-block align-top">
1487
+ <button ref={revBtnRef} onClick={(e)=>{ e.preventDefault(); e.stopPropagation(); const r=(e.currentTarget as HTMLElement).getBoundingClientRect(); setRevMenuPos({ left: r.left, top: r.bottom + 8 }); setRevDownloadOpen(o=>!o); }} 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">Download</button>
1488
+ {revDownloadOpen && revMenuPos && createPortal(
1489
+ <div style={{ position: 'fixed', left: revMenuPos.left, top: revMenuPos.top, zIndex: 10000 }} className="w-56 rounded-md border border-gray-200 bg-white shadow-lg text-left">
1490
+ <button onClick={async()=>{ setRevDownloadOpen(false); try { const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const filename=`${(taskTitle||'Task').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_${(username||'User').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}.docx`; const body={ current: text||'', filename }; const 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(); const url=window.URL.createObjectURL(blob); const link=document.createElement('a'); link.href=url; link.download=filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);} catch {} }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Without Annotations</button>
1491
+ <button onClick={async()=>{ setRevDownloadOpen(false); try { const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const filename=`${(taskTitle||'Task').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_${(username||'User').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_annotated.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 }; const resp=await fetch(`${base}/api/refinity/export-plain-with-annotations`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body) }); if(!resp.ok) throw new Error('Export failed'); const blob=await resp.blob(); const url=window.URL.createObjectURL(blob); const link=document.createElement('a'); link.href=url; link.download=filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);} catch {} }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">With Annotations</button>
1492
+ </div>, document.body
1493
+ )}
1494
  </div>
1495
+ {/* Comments button removed per request */}
1496
  <button onClick={onBack} className="ml-auto 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">Back</button>
1497
  </div>
1498
+ {/* Inline comments drawer removed */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1499
  {showDiff && (
1500
  <div className="mt-6 relative rounded-xl">
1501
  <div className="absolute inset-0 rounded-xl bg-gradient-to-r from-indigo-200/45 via-indigo-100/40 to-indigo-300/45" />
 
1508
  )}
1509
  </div>
1510
  </div>
1511
+ {/* Annotation modal */}
1512
+ {modalOpen && (
1513
+ <div className="fixed inset-0 z-[5000] flex items-center justify-center bg-black/40">
1514
+ <div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
1515
+ <h3 className="text-lg font-semibold text-gray-900 mb-4">{editingAnn ? 'Edit annotation' : 'New annotation'}</h3>
1516
+ <div className="space-y-4">
1517
+ <div>
1518
+ <label className="block text-sm text-gray-700 mb-1">Category <span className="text-red-500">*</span></label>
1519
+ <select
1520
+ value={modalCategory}
1521
+ onChange={(e)=>setModalCategory(e.target.value as AnnotationCategory)}
1522
+ className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm"
1523
+ >
1524
+ <option value="distortion">Distortion</option>
1525
+ <option value="omission">Unjustified omission</option>
1526
+ <option value="register">Inappropriate register</option>
1527
+ <option value="unidiomatic">Unidiomatic expression</option>
1528
+ <option value="grammar">Error of grammar, syntax</option>
1529
+ <option value="spelling">Error of spelling</option>
1530
+ <option value="punctuation">Error of punctuation</option>
1531
+ <option value="addition">Unjustified addition</option>
1532
+ <option value="other">Other</option>
1533
+ </select>
1534
+ </div>
1535
+ <div>
1536
+ <label className="block text-sm text-gray-700 mb-1">Comment (optional)</label>
1537
+ <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…" />
1538
+ </div>
1539
+ </div>
1540
+ <div className="mt-6 flex items-center justify-between">
1541
+ {editingAnn && (
1542
+ <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>
1543
+ )}
1544
+ <div className="ml-auto flex gap-2">
1545
+ <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>
1546
+ <button
1547
+ onClick={async ()=>{
1548
+ if (editingAnn) {
1549
+ const updated = { ...editingAnn, category: modalCategory, comment: modalComment, updatedAt: Date.now() };
1550
+ updateAnnotation(updated);
1551
+ await persistUpdate(updated);
1552
+ } else if (popover) {
1553
+ 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() };
1554
+ const saved = await persistCreate(local);
1555
+ addAnnotation(saved);
1556
+ }
1557
+ setModalOpen(false); setPopover(null); setEditingAnn(null);
1558
+ }}
1559
+ className="px-3 py-1.5 text-sm rounded-lg text-white bg-indigo-600 hover:bg-indigo-700"
1560
+ >
1561
+ Save
1562
+ </button>
1563
+ </div>
1564
+ </div>
1565
+ </div>
1566
+ </div>
1567
+ )}
1568
  </div>
1569
  );
1570
  };
client/src/pages/Toolkit.tsx CHANGED
@@ -374,9 +374,9 @@ const Toolkit: React.FC = () => {
374
  {tools.map(t => {
375
  const isActive = selectedTool === t.key;
376
  return (
377
- <button
378
- key={t.key}
379
- onClick={() => handleToolChange(t.key)}
380
  className={`relative inline-flex items-center justify-center rounded-2xl px-4 py-1.5 whitespace-nowrap transition-all duration-300 ease-out ring-1 ring-inset ${isActive ? 'ring-white/50 backdrop-brightness-110 backdrop-saturate-150' : 'ring-white/30'} backdrop-blur-md isolate shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)]`}
381
  style={{ background: 'rgba(255,255,255,0.10)' }}
382
  >
@@ -393,7 +393,7 @@ const Toolkit: React.FC = () => {
393
  {/* Tint overlay: Indigo family to match Toolkit */}
394
  <div className={`pointer-events-none absolute inset-0 rounded-2xl ${isActive ? 'bg-indigo-600/70 mix-blend-normal opacity-100' : 'bg-indigo-500/30 mix-blend-overlay opacity-35'}`} />
395
  <span className={`relative z-10 text-sm font-medium ${isActive ? 'text-white' : 'text-black'}`}>{t.name}</span>
396
- </button>
397
  );
398
  })}
399
  </div>
 
374
  {tools.map(t => {
375
  const isActive = selectedTool === t.key;
376
  return (
377
+ <button
378
+ key={t.key}
379
+ onClick={() => handleToolChange(t.key)}
380
  className={`relative inline-flex items-center justify-center rounded-2xl px-4 py-1.5 whitespace-nowrap transition-all duration-300 ease-out ring-1 ring-inset ${isActive ? 'ring-white/50 backdrop-brightness-110 backdrop-saturate-150' : 'ring-white/30'} backdrop-blur-md isolate shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)]`}
381
  style={{ background: 'rgba(255,255,255,0.10)' }}
382
  >
 
393
  {/* Tint overlay: Indigo family to match Toolkit */}
394
  <div className={`pointer-events-none absolute inset-0 rounded-2xl ${isActive ? 'bg-indigo-600/70 mix-blend-normal opacity-100' : 'bg-indigo-500/30 mix-blend-overlay opacity-35'}`} />
395
  <span className={`relative z-10 text-sm font-medium ${isActive ? 'text-white' : 'text-black'}`}>{t.name}</span>
396
+ </button>
397
  );
398
  })}
399
  </div>
client/src/pages/TutorialTasks.tsx CHANGED
@@ -957,8 +957,8 @@ const TutorialTasks: React.FC = () => {
957
  console.log('[Trace] ScrollPreserve:before', { taskId, topBefore });
958
  } catch {}
959
  React.startTransition(() => {
960
- setTranslationText({ ...translationText, [taskId]: '' });
961
- setSelectedGroups({ ...selectedGroups, [taskId]: 0 });
962
  });
963
  if (!isSafari) {
964
  // Narrow refetch immediately for non-Safari
@@ -970,7 +970,7 @@ const TutorialTasks: React.FC = () => {
970
  }).catch(() => {
971
  requestAnimationFrame(() => setSpacerHeights(prev => ({ ...prev, [taskId]: 0 })));
972
  });
973
- } else {
974
  // Safari: delay refetch and keep locks until refetch completes
975
  pendingUnlocksRef.current.add(taskId);
976
  setTimeout(() => {
@@ -2468,8 +2468,8 @@ const TutorialTasks: React.FC = () => {
2468
  </button>
2469
  </div>
2470
  <div ref={(el) => { submissionsGridRefs.current[task._id] = el; }} className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 ${
2471
- expandedSections[task._id]
2472
- ? 'max-h-none overflow-visible'
2473
  : 'max-h-0 overflow-hidden'
2474
  }`} data-grid-id={task._id}>
2475
  {userSubmissions[task._id].map((submission, index) => (
 
957
  console.log('[Trace] ScrollPreserve:before', { taskId, topBefore });
958
  } catch {}
959
  React.startTransition(() => {
960
+ setTranslationText({ ...translationText, [taskId]: '' });
961
+ setSelectedGroups({ ...selectedGroups, [taskId]: 0 });
962
  });
963
  if (!isSafari) {
964
  // Narrow refetch immediately for non-Safari
 
970
  }).catch(() => {
971
  requestAnimationFrame(() => setSpacerHeights(prev => ({ ...prev, [taskId]: 0 })));
972
  });
973
+ } else {
974
  // Safari: delay refetch and keep locks until refetch completes
975
  pendingUnlocksRef.current.add(taskId);
976
  setTimeout(() => {
 
2468
  </button>
2469
  </div>
2470
  <div ref={(el) => { submissionsGridRefs.current[task._id] = el; }} className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 ${
2471
+ expandedSections[task._id]
2472
+ ? 'max-h-none overflow-visible'
2473
  : 'max-h-0 overflow-hidden'
2474
  }`} data-grid-id={task._id}>
2475
  {userSubmissions[task._id].map((submission, index) => (
client/src/pages/WeeklyPractice.tsx CHANGED
@@ -1458,9 +1458,9 @@ const WeeklyPractice: React.FC = () => {
1458
  {weeks.map((week) => {
1459
  const isActive = selectedWeek === week;
1460
  return (
1461
- <button
1462
- key={week}
1463
- onClick={() => handleWeekChange(week)}
1464
  className={`relative inline-flex items-center justify-center rounded-2xl px-4 py-1.5 whitespace-nowrap transition-all duration-300 ease-out ring-1 ring-inset ${isActive ? 'ring-white/50 backdrop-brightness-110 backdrop-saturate-150' : 'ring-white/30'} backdrop-blur-md isolate shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)]`}
1465
  style={{ background: 'rgba(255,255,255,0.10)' }}
1466
  >
@@ -1477,7 +1477,7 @@ const WeeklyPractice: React.FC = () => {
1477
  {/* Pink tint overlay to match Weekly Practice identity */}
1478
  <div className={`pointer-events-none absolute inset-0 rounded-2xl ${isActive ? 'bg-pink-600/70 mix-blend-normal opacity-100' : 'bg-pink-500/30 mix-blend-overlay opacity-35'}`} />
1479
  <span className={`relative z-10 text-sm font-medium ${isActive ? 'text-white' : 'text-black'}`}>Week {week}</span>
1480
- </button>
1481
  );
1482
  })}
1483
  </div>
@@ -1752,204 +1752,204 @@ const WeeklyPractice: React.FC = () => {
1752
  </div>
1753
  {((localStorage.getItem('viewMode')||'auto') !== 'student' && (JSON.parse(localStorage.getItem('user')||'{}').role === 'admin') && showSubmissions) && (
1754
  <>
1755
- <div className="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
1756
- <div className="mb-4">
1757
- <h3 className="text-xl font-bold text-gray-900 mb-2">{videoInfo.title}</h3>
1758
- </div>
1759
-
1760
- {/* Video Player */}
1761
- <div className="bg-black rounded-lg aspect-video overflow-hidden relative">
1762
- <video
1763
- className="w-full h-full"
1764
- controls
1765
- preload="metadata"
1766
- id="nike-video"
1767
- onError={(e) => console.error('Video loading error:', e)}
1768
- onLoadStart={() => console.log('Video loading started')}
1769
- onCanPlay={() => console.log('Video can play')}
1770
- >
1771
- <source src="https://huggingface.co/spaces/linguabot/transcreation-frontend/resolve/main/public/videos/nike-winning-isnt-for-everyone.mp4" type="video/mp4" />
1772
- Your browser does not support the video tag.
1773
- </video>
1774
-
1775
- {/* On-screen subtitles */}
1776
- {currentDisplayedSubtitle && (
1777
- <div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 z-10">
1778
- <div className="bg-black bg-opacity-80 text-white px-6 py-3 rounded-lg text-center max-w-lg">
1779
- <p className={`text-base font-medium leading-relaxed tracking-wide ${currentDisplayedSubtitle.length <= 42 ? 'whitespace-nowrap' : 'whitespace-pre-line'}`} style={{ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }}>
1780
- {formatSubtitleForDisplay(currentDisplayedSubtitle)}
1781
- </p>
1782
- </div>
1783
- </div>
1784
- )}
1785
- </div>
1786
  </div>
1787
-
1788
- {/* Right Panel - Translation Workspace */}
1789
- <div className="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
1790
- <h3 className="text-xl font-bold text-gray-900 mb-4">Translation Workspace</h3>
 
 
 
 
 
 
 
 
 
 
 
1791
 
1792
- {/* Segment Navigation Grid */}
1793
- {loadingSubtitles ? (
1794
- <div className="grid grid-cols-9 gap-1 mb-6">
1795
- {Array.from({ length: 26 }, (_, i) => (
1796
- <div key={i} className="px-1 py-1 rounded text-xs w-full bg-gray-200 animate-pulse">
1797
- {i + 1}
1798
- </div>
1799
- ))}
1800
- </div>
1801
- ) : (
1802
- <div className="grid grid-cols-9 gap-1 mb-6">
1803
- {subtitleSegments.map((segment) => (
1804
- <button
1805
- key={segment.id}
1806
- onClick={() => handleSegmentClick(segment.id)}
1807
- className={`px-1 py-1 rounded text-xs w-full ${getSegmentButtonClass(segment.id)}`}
1808
- >
1809
- {segment.id}
1810
- </button>
1811
- ))}
1812
  </div>
1813
  )}
1814
-
1815
- {/* Admin Time Code Editor */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1816
  {editingSegment && (((localStorage.getItem('viewMode')||'auto') === 'student') ? false : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin')) && (
1817
- <div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4 mb-4">
1818
- <h4 className="text-sm font-medium text-indigo-800 mb-2">Edit Time Codes for Segment {editingSegment}</h4>
1819
- <div className="grid grid-cols-2 gap-2">
1820
- <div>
1821
- <label className="block text-xs text-indigo-700 mb-1">Start Time</label>
1822
- <input
1823
- type="text"
1824
- id="startTimeInput"
1825
- defaultValue={subtitleSegments[editingSegment - 1]?.startTime}
1826
- className="w-full px-2 py-1 text-xs border border-indigo-300 rounded"
1827
- placeholder="00:00:00,000"
1828
- />
1829
- </div>
1830
- <div>
1831
- <label className="block text-xs text-indigo-700 mb-1">End Time</label>
1832
- <input
1833
- type="text"
1834
- id="endTimeInput"
1835
- defaultValue={subtitleSegments[editingSegment - 1]?.endTime}
1836
- className="w-full px-2 py-1 text-xs border border-indigo-300 rounded"
1837
- placeholder="00:00:00,000"
1838
- />
1839
- </div>
1840
  </div>
1841
- <div className="flex space-x-2 mt-2">
1842
- <button
1843
- onClick={() => setEditingSegment(null)}
1844
- className="px-2 py-1 text-xs bg-gray-500 text-white rounded"
1845
- >
1846
- Cancel
1847
- </button>
1848
- <button
1849
- onClick={() => {
1850
- const startInput = document.getElementById('startTimeInput') as HTMLInputElement;
1851
- const endInput = document.getElementById('endTimeInput') as HTMLInputElement;
1852
- if (startInput && endInput && startInput.value && endInput.value) {
1853
- handleSaveTimeCode(editingSegment, startInput.value, endInput.value);
1854
- }
1855
- }}
1856
- className="px-2 py-1 text-xs bg-indigo-600 text-white rounded"
1857
- >
1858
- Save
1859
- </button>
1860
  </div>
1861
  </div>
1862
- )}
1863
-
1864
- {/* Current Segment Details */}
1865
- <div className="bg-gray-50 rounded-lg p-4 mb-4 border border-gray-200">
1866
- <div className="flex items-center justify-between">
1867
- <p className="text-gray-800">
1868
- Segment {currentSegment}: {subtitleSegments[currentSegment - 1]?.startTime} – {subtitleSegments[currentSegment - 1]?.endTime} ({subtitleSegments[currentSegment - 1]?.duration})
1869
- </p>
1870
- {((localStorage.getItem('viewMode')||'auto') === 'student') ? false : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin') && (
1871
- <button
1872
- onClick={() => handleEditTimeCode(currentSegment)}
1873
- className="bg-indigo-600 hover:bg-indigo-700 text-white px-2 py-1 rounded text-xs flex items-center space-x-1"
1874
- title="Edit time codes"
1875
- >
1876
- <PencilIcon className="w-3 h-3" />
1877
- <span>Edit</span>
1878
- </button>
1879
- )}
 
1880
  </div>
1881
  </div>
1882
-
1883
- {/* Source Text */}
1884
- <div className="mb-4">
1885
- <label className="block text-sm font-medium text-gray-700 mb-2">Source Text</label>
1886
- <textarea
1887
- value={subtitleText}
1888
- readOnly
1889
- className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-800"
1890
- rows={2}
1891
- />
 
 
 
 
 
 
 
 
1892
  </div>
1893
-
1894
- {/* Target Text */}
1895
- <div className="mb-4">
1896
- <label className="block text-sm font-medium text-gray-700 mb-2">Target Text</label>
1897
- <textarea
1898
- value={targetText}
1899
- onChange={(e) => handleTargetTextChange(e.target.value)}
1900
- className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
1901
- rows={2}
1902
- placeholder="Enter your translation..."
1903
- />
1904
- <div className="flex justify-between items-center mt-2">
1905
- <span className="text-xs text-gray-600">Recommended: 2 lines max, 16 chars/line (Netflix standard)</span>
1906
- <span className="text-xs text-gray-600">{characterCount} characters</span>
1907
- </div>
 
 
 
 
 
 
 
 
 
 
 
1908
  </div>
1909
-
1910
- {/* Action Buttons */}
1911
- <div className="flex space-x-3 mb-4">
1912
- <button
1913
- onClick={handleSaveTranslation}
1914
- className={`px-4 py-2 rounded-lg ${
1915
- saveStatus === 'Saved!' ? 'bg-green-600 hover:bg-green-700' :
1916
- saveStatus === 'Saved Locally' ? 'bg-yellow-600 hover:bg-yellow-700' :
1917
- saveStatus === 'Error' ? 'bg-red-600 hover:bg-red-700' :
1918
- 'bg-indigo-600 hover:bg-indigo-700'
1919
- } text-white`}
1920
- >
1921
- {saveStatus}
1922
- </button>
1923
- <button
1924
- onClick={handlePreviewTranslation}
1925
- className={`px-4 py-2 rounded-lg flex items-center space-x-2 ${
1926
- showTranslatedSubtitles
1927
- ? 'bg-green-500 hover:bg-green-600 text-white'
1928
- : 'bg-gray-500 hover:bg-gray-600 text-white'
1929
- }`}
1930
- >
1931
- <span>{showTranslatedSubtitles ? 'Show Original' : 'Show Translation'}</span>
1932
- </button>
 
 
 
 
 
 
 
 
1933
  </div>
1934
-
1935
- {/* Translation Progress */}
1936
- <div className="mb-4">
1937
- <div className="flex justify-between items-center mb-2">
1938
- <span className="text-sm font-medium text-gray-700">Translation Progress</span>
1939
- <span className="text-sm text-gray-600">{Object.keys(subtitleTranslations).length} of {subtitleSegments.length} segments completed</span>
1940
- </div>
1941
- <div className="w-full bg-gray-200 rounded-full h-2">
1942
- <div
1943
- className="bg-green-500 h-2 rounded-full transition-all duration-300"
1944
- style={{ width: `${(Object.keys(subtitleTranslations).length / subtitleSegments.length) * 100}%` }}
1945
- ></div>
1946
- </div>
1947
  </div>
1948
  </div>
1949
- </>
1950
- )}
1951
- </div>
1952
- )}
 
1953
  <div className="space-y-6">
1954
  {/* Show Subtitling UI Button for Week 2 Admin */}
1955
  {selectedWeek === 2 && (() => {
@@ -1959,8 +1959,8 @@ const WeeklyPractice: React.FC = () => {
1959
  })() && (
1960
  <div className="mb-4">
1961
  <button onClick={() => setShowSubmissions(v=>!v)} className="px-3 py-1.5 text-sm rounded-md border border-gray-300">{showSubmissions ? 'Hide Subtitling UI' : 'Show Subtitling UI (Admin)'}</button>
1962
- </div>
1963
- )}
1964
  {/* Add Practice Button for Admin */}
1965
  {(() => {
1966
  const user = JSON.parse(localStorage.getItem('user') || '{}');
@@ -2592,9 +2592,9 @@ const WeeklyPractice: React.FC = () => {
2592
  )}
2593
  </div>
2594
  ))}
2595
- </>
2596
- )}
2597
- </div>
2598
 
2599
  {/* Edit Submission Modal */}
2600
  {editingSubmission && (
 
1458
  {weeks.map((week) => {
1459
  const isActive = selectedWeek === week;
1460
  return (
1461
+ <button
1462
+ key={week}
1463
+ onClick={() => handleWeekChange(week)}
1464
  className={`relative inline-flex items-center justify-center rounded-2xl px-4 py-1.5 whitespace-nowrap transition-all duration-300 ease-out ring-1 ring-inset ${isActive ? 'ring-white/50 backdrop-brightness-110 backdrop-saturate-150' : 'ring-white/30'} backdrop-blur-md isolate shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)]`}
1465
  style={{ background: 'rgba(255,255,255,0.10)' }}
1466
  >
 
1477
  {/* Pink tint overlay to match Weekly Practice identity */}
1478
  <div className={`pointer-events-none absolute inset-0 rounded-2xl ${isActive ? 'bg-pink-600/70 mix-blend-normal opacity-100' : 'bg-pink-500/30 mix-blend-overlay opacity-35'}`} />
1479
  <span className={`relative z-10 text-sm font-medium ${isActive ? 'text-white' : 'text-black'}`}>Week {week}</span>
1480
+ </button>
1481
  );
1482
  })}
1483
  </div>
 
1752
  </div>
1753
  {((localStorage.getItem('viewMode')||'auto') !== 'student' && (JSON.parse(localStorage.getItem('user')||'{}').role === 'admin') && showSubmissions) && (
1754
  <>
1755
+ <div className="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
1756
+ <div className="mb-4">
1757
+ <h3 className="text-xl font-bold text-gray-900 mb-2">{videoInfo.title}</h3>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1758
  </div>
1759
+
1760
+ {/* Video Player */}
1761
+ <div className="bg-black rounded-lg aspect-video overflow-hidden relative">
1762
+ <video
1763
+ className="w-full h-full"
1764
+ controls
1765
+ preload="metadata"
1766
+ id="nike-video"
1767
+ onError={(e) => console.error('Video loading error:', e)}
1768
+ onLoadStart={() => console.log('Video loading started')}
1769
+ onCanPlay={() => console.log('Video can play')}
1770
+ >
1771
+ <source src="https://huggingface.co/spaces/linguabot/transcreation-frontend/resolve/main/public/videos/nike-winning-isnt-for-everyone.mp4" type="video/mp4" />
1772
+ Your browser does not support the video tag.
1773
+ </video>
1774
 
1775
+ {/* On-screen subtitles */}
1776
+ {currentDisplayedSubtitle && (
1777
+ <div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 z-10">
1778
+ <div className="bg-black bg-opacity-80 text-white px-6 py-3 rounded-lg text-center max-w-lg">
1779
+ <p className={`text-base font-medium leading-relaxed tracking-wide ${currentDisplayedSubtitle.length <= 42 ? 'whitespace-nowrap' : 'whitespace-pre-line'}`} style={{ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }}>
1780
+ {formatSubtitleForDisplay(currentDisplayedSubtitle)}
1781
+ </p>
1782
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
1783
  </div>
1784
  )}
1785
+ </div>
1786
+ </div>
1787
+
1788
+ {/* Right Panel - Translation Workspace */}
1789
+ <div className="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
1790
+ <h3 className="text-xl font-bold text-gray-900 mb-4">Translation Workspace</h3>
1791
+
1792
+ {/* Segment Navigation Grid */}
1793
+ {loadingSubtitles ? (
1794
+ <div className="grid grid-cols-9 gap-1 mb-6">
1795
+ {Array.from({ length: 26 }, (_, i) => (
1796
+ <div key={i} className="px-1 py-1 rounded text-xs w-full bg-gray-200 animate-pulse">
1797
+ {i + 1}
1798
+ </div>
1799
+ ))}
1800
+ </div>
1801
+ ) : (
1802
+ <div className="grid grid-cols-9 gap-1 mb-6">
1803
+ {subtitleSegments.map((segment) => (
1804
+ <button
1805
+ key={segment.id}
1806
+ onClick={() => handleSegmentClick(segment.id)}
1807
+ className={`px-1 py-1 rounded text-xs w-full ${getSegmentButtonClass(segment.id)}`}
1808
+ >
1809
+ {segment.id}
1810
+ </button>
1811
+ ))}
1812
+ </div>
1813
+ )}
1814
+
1815
+ {/* Admin Time Code Editor */}
1816
  {editingSegment && (((localStorage.getItem('viewMode')||'auto') === 'student') ? false : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin')) && (
1817
+ <div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4 mb-4">
1818
+ <h4 className="text-sm font-medium text-indigo-800 mb-2">Edit Time Codes for Segment {editingSegment}</h4>
1819
+ <div className="grid grid-cols-2 gap-2">
1820
+ <div>
1821
+ <label className="block text-xs text-indigo-700 mb-1">Start Time</label>
1822
+ <input
1823
+ type="text"
1824
+ id="startTimeInput"
1825
+ defaultValue={subtitleSegments[editingSegment - 1]?.startTime}
1826
+ className="w-full px-2 py-1 text-xs border border-indigo-300 rounded"
1827
+ placeholder="00:00:00,000"
1828
+ />
 
 
 
 
 
 
 
 
 
 
 
1829
  </div>
1830
+ <div>
1831
+ <label className="block text-xs text-indigo-700 mb-1">End Time</label>
1832
+ <input
1833
+ type="text"
1834
+ id="endTimeInput"
1835
+ defaultValue={subtitleSegments[editingSegment - 1]?.endTime}
1836
+ className="w-full px-2 py-1 text-xs border border-indigo-300 rounded"
1837
+ placeholder="00:00:00,000"
1838
+ />
 
 
 
 
 
 
 
 
 
 
1839
  </div>
1840
  </div>
1841
+ <div className="flex space-x-2 mt-2">
1842
+ <button
1843
+ onClick={() => setEditingSegment(null)}
1844
+ className="px-2 py-1 text-xs bg-gray-500 text-white rounded"
1845
+ >
1846
+ Cancel
1847
+ </button>
1848
+ <button
1849
+ onClick={() => {
1850
+ const startInput = document.getElementById('startTimeInput') as HTMLInputElement;
1851
+ const endInput = document.getElementById('endTimeInput') as HTMLInputElement;
1852
+ if (startInput && endInput && startInput.value && endInput.value) {
1853
+ handleSaveTimeCode(editingSegment, startInput.value, endInput.value);
1854
+ }
1855
+ }}
1856
+ className="px-2 py-1 text-xs bg-indigo-600 text-white rounded"
1857
+ >
1858
+ Save
1859
+ </button>
1860
  </div>
1861
  </div>
1862
+ )}
1863
+
1864
+ {/* Current Segment Details */}
1865
+ <div className="bg-gray-50 rounded-lg p-4 mb-4 border border-gray-200">
1866
+ <div className="flex items-center justify-between">
1867
+ <p className="text-gray-800">
1868
+ Segment {currentSegment}: {subtitleSegments[currentSegment - 1]?.startTime} – {subtitleSegments[currentSegment - 1]?.endTime} ({subtitleSegments[currentSegment - 1]?.duration})
1869
+ </p>
1870
+ {((localStorage.getItem('viewMode')||'auto') === 'student') ? false : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin') && (
1871
+ <button
1872
+ onClick={() => handleEditTimeCode(currentSegment)}
1873
+ className="bg-indigo-600 hover:bg-indigo-700 text-white px-2 py-1 rounded text-xs flex items-center space-x-1"
1874
+ title="Edit time codes"
1875
+ >
1876
+ <PencilIcon className="w-3 h-3" />
1877
+ <span>Edit</span>
1878
+ </button>
1879
+ )}
1880
  </div>
1881
+ </div>
1882
+
1883
+ {/* Source Text */}
1884
+ <div className="mb-4">
1885
+ <label className="block text-sm font-medium text-gray-700 mb-2">Source Text</label>
1886
+ <textarea
1887
+ value={subtitleText}
1888
+ readOnly
1889
+ className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-800"
1890
+ rows={2}
1891
+ />
1892
+ </div>
1893
+
1894
+ {/* Target Text */}
1895
+ <div className="mb-4">
1896
+ <label className="block text-sm font-medium text-gray-700 mb-2">Target Text</label>
1897
+ <textarea
1898
+ value={targetText}
1899
+ onChange={(e) => handleTargetTextChange(e.target.value)}
1900
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
1901
+ rows={2}
1902
+ placeholder="Enter your translation..."
1903
+ />
1904
+ <div className="flex justify-between items-center mt-2">
1905
+ <span className="text-xs text-gray-600">Recommended: 2 lines max, 16 chars/line (Netflix standard)</span>
1906
+ <span className="text-xs text-gray-600">{characterCount} characters</span>
1907
  </div>
1908
+ </div>
1909
+
1910
+ {/* Action Buttons */}
1911
+ <div className="flex space-x-3 mb-4">
1912
+ <button
1913
+ onClick={handleSaveTranslation}
1914
+ className={`px-4 py-2 rounded-lg ${
1915
+ saveStatus === 'Saved!' ? 'bg-green-600 hover:bg-green-700' :
1916
+ saveStatus === 'Saved Locally' ? 'bg-yellow-600 hover:bg-yellow-700' :
1917
+ saveStatus === 'Error' ? 'bg-red-600 hover:bg-red-700' :
1918
+ 'bg-indigo-600 hover:bg-indigo-700'
1919
+ } text-white`}
1920
+ >
1921
+ {saveStatus}
1922
+ </button>
1923
+ <button
1924
+ onClick={handlePreviewTranslation}
1925
+ className={`px-4 py-2 rounded-lg flex items-center space-x-2 ${
1926
+ showTranslatedSubtitles
1927
+ ? 'bg-green-500 hover:bg-green-600 text-white'
1928
+ : 'bg-gray-500 hover:bg-gray-600 text-white'
1929
+ }`}
1930
+ >
1931
+ <span>{showTranslatedSubtitles ? 'Show Original' : 'Show Translation'}</span>
1932
+ </button>
1933
+ </div>
1934
+
1935
+ {/* Translation Progress */}
1936
+ <div className="mb-4">
1937
+ <div className="flex justify-between items-center mb-2">
1938
+ <span className="text-sm font-medium text-gray-700">Translation Progress</span>
1939
+ <span className="text-sm text-gray-600">{Object.keys(subtitleTranslations).length} of {subtitleSegments.length} segments completed</span>
1940
  </div>
1941
+ <div className="w-full bg-gray-200 rounded-full h-2">
1942
+ <div
1943
+ className="bg-green-500 h-2 rounded-full transition-all duration-300"
1944
+ style={{ width: `${(Object.keys(subtitleTranslations).length / subtitleSegments.length) * 100}%` }}
1945
+ ></div>
 
 
 
 
 
 
 
 
1946
  </div>
1947
  </div>
1948
+ </div>
1949
+ </>
1950
+ )}
1951
+ </div>
1952
+ )}
1953
  <div className="space-y-6">
1954
  {/* Show Subtitling UI Button for Week 2 Admin */}
1955
  {selectedWeek === 2 && (() => {
 
1959
  })() && (
1960
  <div className="mb-4">
1961
  <button onClick={() => setShowSubmissions(v=>!v)} className="px-3 py-1.5 text-sm rounded-md border border-gray-300">{showSubmissions ? 'Hide Subtitling UI' : 'Show Subtitling UI (Admin)'}</button>
1962
+ </div>
1963
+ )}
1964
  {/* Add Practice Button for Admin */}
1965
  {(() => {
1966
  const user = JSON.parse(localStorage.getItem('user') || '{}');
 
2592
  )}
2593
  </div>
2594
  ))}
2595
+ </>
2596
+ )}
2597
+ </div>
2598
 
2599
  {/* Edit Submission Modal */}
2600
  {editingSubmission && (