import React from 'react'; import { createPortal } from 'react-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { Swiper as SwiperRoot, SwiperSlide } from 'swiper/react'; import type { Swiper as SwiperInstance } from 'swiper'; import { EffectCoverflow, Navigation, Pagination, A11y } from 'swiper/modules'; import 'swiper/css'; import 'swiper/css/effect-coverflow'; import 'swiper/css/navigation'; import 'swiper/css/pagination'; import { api } from '../services/api'; type Stage = 'task' | 'flow' | 'preview' | 'editor'; type Version = { id: string; taskId: string; originalAuthor: string; revisedBy?: string; versionNumber: number; // 1-based content: string; // translated text parentVersionId?: string; // lineage }; type AnnotationCategory = | 'distortion' | 'omission' | 'register' | 'unidiomatic' | 'grammar' | 'spelling' | 'punctuation' | 'addition' | 'other'; type Annotation = { id: string; versionId: string; start: number; // inclusive, char index in version.content end: number; // exclusive category: AnnotationCategory; comment?: string; correction?: string; createdBy?: string; createdAt: number; updatedAt: number; }; type Task = { id: string; title: string; sourceText: string; createdBy?: string; }; // ---- Filename helpers ---- function toSafeName(input: string): string { // Keep CJK and most unicode letters; remove only forbidden filename characters and collapse whitespace return String(input || '') .replace(/[\\/:*?"<>|]+/g, '_') // forbidden on Windows/macOS .replace(/[\u0000-\u001F\u007F]/g, '') // control chars .trim() .replace(/\s+/g, '_'); } function pad2(n: number): string { return n < 10 ? `0${n}` : String(n); } function yyyymmdd_hhmm(d = new Date()): string { const Y = d.getFullYear(); const M = pad2(d.getMonth() + 1); const D = pad2(d.getDate()); const h = pad2(d.getHours()); const m = pad2(d.getMinutes()); return `${Y}${M}${D}_${h}${m}`; } // Download helpers with fail-safes async function saveBlobToDisk(blob: Blob, filename: string) { 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); } function saveTextFallback(text: string, filename: string) { const blob = new Blob([text || ''], { type: 'text/plain;charset=utf-8' }); const fallbackName = filename.replace(/\.docx$/i, '') + '.txt'; return saveBlobToDisk(blob, fallbackName); } async function tryFetchDocx(url: string, body: any): Promise { return await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } const mockTasks: Task[] = [ { id: 't1', title: 'Refinity Demo Task 1', sourceText: 'The quick brown fox jumps over the lazy dog.' }, { id: 't2', title: 'Refinity Demo Task 2', sourceText: 'To be, or not to be, that is the question.' }, ]; const Refinity: React.FC = () => { // Check if we're in tutorial mode - use state to make it reactive const [isTutorialMode, setIsTutorialMode] = React.useState(() => { try { return localStorage.getItem('refinityMode') === 'tutorial'; } catch { return false; } }); // Listen for changes to tutorial mode React.useEffect(() => { const checkTutorialMode = () => { try { setIsTutorialMode(localStorage.getItem('refinityMode') === 'tutorial'); } catch { setIsTutorialMode(false); } }; // Check immediately checkTutorialMode(); // Listen for storage events (when TutorialRefinity sets it) window.addEventListener('storage', checkTutorialMode); // Also poll periodically to catch changes from same window const interval = setInterval(checkTutorialMode, 100); return () => { window.removeEventListener('storage', checkTutorialMode); clearInterval(interval); }; }, []); const getApiBase = React.useCallback((endpoint: string) => { // Use localhost when running locally, otherwise use api base const isLocalhost = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname); const base = isLocalhost ? 'http://localhost:5000' : (((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '')); // Check localStorage directly to ensure we always have the latest value try { const mode = localStorage.getItem('refinityMode'); if (mode === 'tutorial') { return `${base}/api/tutorial-refinity${endpoint}`; } } catch {} return `${base}/api/refinity${endpoint}`; }, []); const [stage, setStage] = React.useState(() => { try { const h = String(window.location.hash || ''); const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : ''; const params = new URLSearchParams(q); const s = String(params.get('stage') || ''); if (s === 'flow' || s === 'preview' || s === 'editor' || s === 'task') return s as any; } catch {} return 'task'; }); const [tasks, setTasks] = React.useState([]); const [selectedTaskId, setSelectedTaskId] = React.useState(() => { try { // Check if in tutorial mode and restore from localStorage const isTutorial = localStorage.getItem('refinityMode') === 'tutorial'; if (isTutorial) { const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); if (weekNumber > 0) { const saved = localStorage.getItem(`tutorial_selected_task_week_${weekNumber}`); if (saved) return saved; } } // Otherwise, try URL hash const h = String(window.location.hash || ''); const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : ''; const params = new URLSearchParams(q); return String(params.get('taskId') || ''); } catch { return ''; } }); // Track user-initiated task selections to prevent effect from overriding them const userSelectedTaskIdRef = React.useRef(null); const [versions, setVersions] = React.useState([]); const [currentVersionId, setCurrentVersionId] = React.useState(() => { try { const h = String(window.location.hash || ''); const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : ''; const params = new URLSearchParams(q); return String(params.get('versionId') || '') || null; } catch { return null; } }); const [previewVersionId, setPreviewVersionId] = React.useState(() => { try { const h = String(window.location.hash || ''); const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : ''; const params = new URLSearchParams(q); return String(params.get('previewId') || '') || null; } catch { return null; } }); const [isFullscreen, setIsFullscreen] = React.useState(() => { try { const h = String(window.location.hash || ''); const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : ''; const params = new URLSearchParams(q); return params.get('fullscreen') === '1'; } catch { return false; } }); const [compareUIOpen, setCompareUIOpen] = React.useState(false); const [compareA, setCompareA] = React.useState(''); const [compareB, setCompareB] = React.useState(''); const [compareModalOpen, setCompareModalOpen] = React.useState(false); const [lastDiffIds, setLastDiffIds] = React.useState<{ a?: string; b?: string }>({}); const [compareDiffHtml, setCompareDiffHtml] = React.useState(''); const [compareLoading, setCompareLoading] = React.useState(false); const [compareDownloadOpen, setCompareDownloadOpen] = React.useState(false); const [compareMenuPos, setCompareMenuPos] = React.useState<{ left: number; top: number } | null>(null); const compareBtnRef = React.useRef(null); const [revDownloadOpen, setRevDownloadOpen] = React.useState(false); // Chrome-only UA flag for layout tweaks that should not affect Safari/Firefox/Edge React.useEffect(() => { try { const ua = navigator.userAgent || ''; const isChrome = /Chrome\//.test(ua) && !/Edg\//.test(ua) && !/OPR\//.test(ua); const root = document.documentElement; if (isChrome) root.classList.add('is-chrome'); else root.classList.remove('is-chrome'); } catch {} }, []); // --- Route persistence (hash-based) --- const restoringRef = React.useRef(false); const appliedInitialRouteRef = React.useRef(false); const initialRouteRef = React.useRef<{ stage?: Stage; taskId?: string; versionId?: string; previewId?: string; fullscreen?: boolean } | null>(null); const parseRouteHash = React.useCallback((): Record => { try { const h = String(window.location.hash || ''); const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : ''; const out: Record = {}; q.split('&').forEach(p => { const [k, v] = p.split('='); if (!k) return; out[decodeURIComponent(k)] = decodeURIComponent(v || ''); }); return out; } catch { return {}; } }, []); const writeRouteHash = React.useCallback((s: Partial<{ stage: Stage; taskId: string; versionId: string; previewId: string; fullscreen: string }>) => { try { if (restoringRef.current) return; const params = new URLSearchParams(); const current = parseRouteHash(); const merged: Record = { ...current, ...Object.fromEntries(Object.entries(s).filter(([,v]) => v !== undefined && v !== null).map(([k,v]) => [k, String(v)])) }; Object.entries(merged).forEach(([k, v]) => { if (v) params.set(k, v); }); const next = `#/dr?${params.toString()}`; if (String(window.location.hash) !== next) { window.location.hash = next; } } catch {} }, [parseRouteHash]); // Annotations (Version Flow) const [showAnnotations, setShowAnnotations] = React.useState(true); const [annotationPopover, setAnnotationPopover] = React.useState<{ left: number; top: number; versionId: string; range: { start: number; end: number } } | null>(null); const [annotationModalOpen, setAnnotationModalOpen] = React.useState(false); const [editingAnnotation, setEditingAnnotation] = React.useState(null); const [modalCategory, setModalCategory] = React.useState('distortion'); const [modalComment, setModalComment] = React.useState(''); const [username] = React.useState(() => { try { const u = localStorage.getItem('user'); const name = u ? (JSON.parse(u)?.name || JSON.parse(u)?.email || 'Anonymous') : 'Anonymous'; return String(name); } catch { return 'Anonymous'; } }); const getIsAdmin = React.useCallback(() => { try { const u = localStorage.getItem('user'); const role = u ? (JSON.parse(u)?.role || '') : ''; const viewMode = (localStorage.getItem('viewMode') || 'auto').toString().toLowerCase(); if (viewMode === 'student') return false; return String(role).toLowerCase() === 'admin'; } catch { return false; } }, []); const [isAdmin, setIsAdmin] = React.useState(() => getIsAdmin()); React.useEffect(() => { const refresh = () => setIsAdmin(getIsAdmin()); window.addEventListener('storage', refresh); const id = window.setInterval(refresh, 500); return () => { window.removeEventListener('storage', refresh); window.clearInterval(id); }; }, [getIsAdmin]); // File upload (.docx placeholder) const [uploading, setUploading] = React.useState(false); const fileInputRef = React.useRef(null); // Add Task UI state const [showAddTask, setShowAddTask] = React.useState(false); const [newTaskTitle, setNewTaskTitle] = React.useState(''); const [newTaskSource, setNewTaskSource] = React.useState(''); const addTaskFileRef = React.useRef(null); const [addingSourceUploading, setAddingSourceUploading] = React.useState(false); const [taskMenuOpen, setTaskMenuOpen] = React.useState(false); const [pastedTranslation, setPastedTranslation] = React.useState(''); const [taskStageNote, setTaskStageNote] = React.useState(''); const [showPreviewDiff, setShowPreviewDiff] = React.useState(false); const [inputTab, setInputTab] = React.useState<'paste' | 'upload'>('paste'); const [editingVersionId, setEditingVersionId] = React.useState(null); const [editingTaskOpen, setEditingTaskOpen] = React.useState(false); const [editTaskTitle, setEditTaskTitle] = React.useState(''); const [editTaskSource, setEditTaskSource] = React.useState(''); const [savingTaskEdit, setSavingTaskEdit] = React.useState(false); const task = React.useMemo(() => tasks.find(t => t.id === selectedTaskId) || tasks[0], [tasks, selectedTaskId]); const taskVersions = React.useMemo(() => versions.filter(v => v.taskId === (task?.id || '')), [versions, task?.id]); // Load tasks // Simple session cache keys - use different keys for tutorial mode to avoid conflicts // Check localStorage directly for tutorial mode const getTutorialMode = React.useCallback(() => { try { return localStorage.getItem('refinityMode') === 'tutorial'; } catch { return false; } }, []); const TASKS_CACHE_KEY = React.useMemo(() => { const isTut = getTutorialMode(); return isTut ? 'tutorial_refinity_tasks_cache_v1' : 'refinity_tasks_cache_v1'; }, [getTutorialMode]); const versionsCacheKey = React.useCallback((taskId: string) => { const isTut = getTutorialMode(); return isTut ? `tutorial_refinity_versions_cache_${taskId}_v1` : `refinity_versions_cache_${taskId}_v1`; }, [getTutorialMode]); React.useEffect(() => { (async () => { try { // Capture initial route once on first load if (!initialRouteRef.current) { const params = parseRouteHash(); const desiredStage = (params.stage as Stage) || undefined; const fullscreen = params.fullscreen === '1'; initialRouteRef.current = { stage: desiredStage, taskId: params.taskId, versionId: params.versionId, previewId: params.previewId, fullscreen }; if (fullscreen) setIsFullscreen(true); } // Hydrate from cache immediately try { const cached = JSON.parse(sessionStorage.getItem(TASKS_CACHE_KEY) || '[]'); if (Array.isArray(cached) && cached.length && tasks.length === 0) { setTasks(cached); // Use a function to get current selectedTaskId to avoid stale closure setSelectedTaskId(prev => { if (prev) return prev; // Don't override if already set const initTaskId = initialRouteRef.current?.taskId; const isTutorial = getTutorialMode(); if (initTaskId && cached.some((t:any)=>t.id===initTaskId)) { if (isTutorial) { const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); if (weekNumber > 0) { localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, initTaskId); } } return initTaskId; } else if (cached.length) { if (isTutorial) { const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); if (weekNumber > 0) { const savedTaskId = localStorage.getItem(`tutorial_selected_task_week_${weekNumber}`); if (savedTaskId && cached.some((t:any)=>t.id===savedTaskId)) { return savedTaskId; } else { localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, cached[0].id); } } } return cached[0].id; } return prev; }); } } catch {} // In tutorial mode, include weekNumber in query let tasksUrl = getApiBase('/tasks'); if (getTutorialMode()) { const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); if (weekNumber > 0) { tasksUrl += `?weekNumber=${weekNumber}`; } } // Send user identification headers for proper backend handling const user = JSON.parse(localStorage.getItem('user') || '{}'); const headers: any = {}; if (user.name) headers['x-user-name'] = user.name; if (user.email) headers['x-user-email'] = user.email; if (user.role === 'admin') { headers['x-user-role'] = 'admin'; headers['user-role'] = 'admin'; } const resp = await fetch(tasksUrl, { headers }); const data: any[] = await resp.json().catch(()=>[]); // In tutorial mode, only show tasks created by admin let normalized: Task[] = Array.isArray(data) ? data.map(d => ({ id: d._id, title: d.title, sourceText: d.sourceText, createdBy: d.createdBy })) : []; const currentTutorialMode = getTutorialMode(); if (currentTutorialMode) { // Filter to only show admin-created tasks const user = JSON.parse(localStorage.getItem('user') || '{}'); const isAdmin = user.role === 'admin'; if (!isAdmin) { // Non-admin users: only show tasks created by admin normalized = normalized.filter(t => { // Check if task was created by admin (createdBy should be admin user ID or email) // For now, we'll show all tasks and let backend filter return true; }); } } setTasks(normalized); try { sessionStorage.setItem(TASKS_CACHE_KEY, JSON.stringify(normalized)); } catch {} // Apply initial route task selection if available // Use functional update to get current state and avoid overriding user selections setSelectedTaskId(prev => { // Don't override if user just selected a task const currentUserSelection = userSelectedTaskIdRef.current; if (currentUserSelection && normalized.some(t => t.id === currentUserSelection)) { return currentUserSelection; } // If current selection is valid, keep it if (prev && normalized.some(t => t.id === prev)) { return prev; } // Otherwise, set initial selection const initTaskId = initialRouteRef.current?.taskId; if (normalized.length) { if (initTaskId && normalized.some(t => t.id === initTaskId)) { if (currentTutorialMode) { const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); if (weekNumber > 0) { localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, initTaskId); } } return initTaskId; } else { // In tutorial mode, try to restore from localStorage if (currentTutorialMode) { const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); if (weekNumber > 0) { const savedTaskId = localStorage.getItem(`tutorial_selected_task_week_${weekNumber}`); if (savedTaskId && normalized.some(t => t.id === savedTaskId)) { return savedTaskId; } else { localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, normalized[0].id); } } } return normalized[0].id; } } return prev; }); } catch {} })(); }, [getApiBase, TASKS_CACHE_KEY, tasks.length, getTutorialMode]); // Load versions when task changes React.useEffect(() => { (async () => { if (!task?.id) { setVersions([]); return; } try { // Hydrate versions from cache for instant UI try { const cached = JSON.parse(sessionStorage.getItem(versionsCacheKey(task.id)) || '[]'); // In tutorial mode, filter to show admin-created first version + user's own versions (unless admin) let filteredCache = cached; const currentTutorialMode = getTutorialMode(); if (currentTutorialMode && !isAdmin) { filteredCache = cached.filter((v: any) => { // Include version 1 (admin-created starting point) OR user's own versions return v.versionNumber === 1 || v.originalAuthor === username || v.revisedBy === username; }); } if (Array.isArray(filteredCache) && filteredCache.length) { setVersions(filteredCache); // If versionId missing but stage requests editor, default to latest cached if (stage === 'editor' && !currentVersionId) { setCurrentVersionId(filteredCache[filteredCache.length - 1]?.id || null); } } } catch {} // Send user identification headers for backend filtering const user = JSON.parse(localStorage.getItem('user') || '{}'); const headers: any = {}; if (user.name) headers['x-user-name'] = user.name; if (user.email) headers['x-user-email'] = user.email; if (user.role === 'admin') { headers['x-user-role'] = 'admin'; headers['user-role'] = 'admin'; } const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(task.id)}/versions`), { headers }); const data: any[] = await resp.json().catch(()=>[]); 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 })) : []; // In tutorial mode, filter to show admin-created first version + user's own versions (unless admin) const currentTutorialMode = getTutorialMode(); if (currentTutorialMode && !isAdmin) { normalized = normalized.filter(v => { // Include version 1 (admin-created starting point) OR user's own versions return v.versionNumber === 1 || v.originalAuthor === username || v.revisedBy === username; }); } setVersions(normalized); try { sessionStorage.setItem(versionsCacheKey(task.id), JSON.stringify(normalized)); } catch {} // Restore stage/version from initial route if present const init = initialRouteRef.current; if (!appliedInitialRouteRef.current && init?.stage && normalized.length && (!init.taskId || init.taskId === task.id)) { restoringRef.current = true; try { if (init.stage === 'editor' && init.versionId && normalized.some(v => v.id === init.versionId)) { setCurrentVersionId(init.versionId); setStage('editor'); } else if (init.stage === 'preview' && init.previewId && normalized.some(v => v.id === init.previewId)) { setPreviewVersionId(init.previewId); setStage('preview'); } else if (init.stage === 'editor') { // Stay in editor; choose latest if specific version missing setCurrentVersionId(normalized[normalized.length-1]?.id || null); setStage('editor'); } else if (init.stage === 'preview') { setPreviewVersionId(normalized[normalized.length-1]?.id || null); setStage('preview'); } else if (init.stage === 'flow') { setStage('flow'); } } finally { // small defer to avoid immediate hash writes during restore setTimeout(() => { restoringRef.current = false; }, 50); appliedInitialRouteRef.current = true; initialRouteRef.current = null; } } else { // No init; do not force stage change if already 'editor' or 'preview' if (!currentVersionId) setCurrentVersionId(normalized.length ? normalized[normalized.length-1].id : null); } } catch { setVersions([]); } })(); }, [task?.id, getApiBase, getTutorialMode, versionsCacheKey, isAdmin]); const deleteVersion = React.useCallback(async (versionId: string) => { try { const ok = window.confirm('Delete this version? This cannot be undone.'); if (!ok) return; const resp = await fetch(getApiBase(`/versions/${encodeURIComponent(versionId)}`), { method: 'DELETE', headers: { 'x-user-name': username, ...(isAdmin ? { 'x-user-role': 'admin' } : {}) } }); if (!resp.ok) throw new Error('Delete failed'); setVersions(prev => prev.filter(v => v.id !== versionId)); } catch {} }, [isAdmin, username]); const saveEditedVersion = React.useCallback(async (versionId: string, newContent: string) => { const resp = await fetch(getApiBase(`/versions/${encodeURIComponent(versionId)}`), { method: 'PUT', headers: { 'Content-Type': 'application/json', 'x-user-name': username }, body: JSON.stringify({ content: newContent }) }); const updated = await resp.json().catch(()=>({})); if (!resp.ok) throw new Error(updated?.error || 'Update failed'); setVersions(prev => prev.map(v => v.id === versionId ? { ...v, content: updated.content } as any : v)); setEditingVersionId(null); setStage('flow'); }, [username]); const openEditTask = React.useCallback(() => { const t = tasks.find(t => t.id === selectedTaskId); if (!t) return; setEditTaskTitle(t.title || ''); setEditTaskSource(t.sourceText || ''); setEditingTaskOpen(true); }, [tasks, selectedTaskId]); const saveTaskEdit = React.useCallback(async () => { if (!selectedTaskId) return; setSavingTaskEdit(true); try { const token = localStorage.getItem('token'); const user = JSON.parse(localStorage.getItem('user') || '{}'); const viewMode = (localStorage.getItem('viewMode') || 'auto'); const effectiveRole = viewMode === 'student' ? 'student' : (user.role || 'visitor'); const headers: any = { 'Content-Type': 'application/json', 'x-user-name': username }; if (token) { headers['Authorization'] = `Bearer ${token}`; } headers['x-user-role'] = effectiveRole; headers['user-role'] = effectiveRole; const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(selectedTaskId)}`), { method: 'PUT', headers, body: JSON.stringify({ title: editTaskTitle, sourceText: editTaskSource }) }); const updated = await resp.json().catch(()=>({})); if (!resp.ok) throw new Error(updated?.error || 'Update failed'); setTasks(prev => prev.map(t => t.id === selectedTaskId ? { ...t, title: updated.title, sourceText: updated.sourceText } : t)); setEditingTaskOpen(false); } catch (e) { alert('Failed to update task'); } finally { setSavingTaskEdit(false); } }, [selectedTaskId, editTaskTitle, editTaskSource, username, getApiBase, isAdmin]); const [flowIndex, setFlowIndex] = React.useState(0); // Keep URL hash in sync with primary navigation state React.useEffect(() => { writeRouteHash({ stage, taskId: task?.id || '', versionId: currentVersionId || '', previewId: previewVersionId || '', fullscreen: isFullscreen ? '1' : '0' }); }, [stage, task?.id, currentVersionId, previewVersionId, isFullscreen, writeRouteHash]); const focusedIndex = React.useMemo(() => { const idx = taskVersions.findIndex(v => v.id === currentVersionId); if (idx >= 0) return idx; return Math.min(Math.max(taskVersions.length - 1, 0), flowIndex); }, [taskVersions, currentVersionId, flowIndex]); const centerVersionId = React.useMemo(() => (taskVersions[flowIndex]?.id) || '', [taskVersions, flowIndex]); const currentVersion = React.useMemo(() => taskVersions.find(v => v.id === currentVersionId) || null, [taskVersions, currentVersionId]); const previewVersion = React.useMemo(() => taskVersions.find(v => v.id === previewVersionId) || null, [taskVersions, previewVersionId]); // Keyboard navigation disabled per request (use arrow buttons only) // Non-visual overlay to guarantee Revise clickability on all platforms without changing layout const reviseBtnRefs = React.useRef>({}); const [reviseOverlay, setReviseOverlay] = React.useState<{ left: number; top: number; width: number; height: number; vid: string } | null>(null); const updateReviseOverlay = React.useCallback(() => { try { const v = taskVersions[flowIndex]; if (!v) { setReviseOverlay(null); return; } const el = reviseBtnRefs.current[v.id || '']; if (!el) { setReviseOverlay(null); return; } const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) { setReviseOverlay(null); return; } setReviseOverlay({ left: Math.max(0, r.left), top: Math.max(0, r.top), width: r.width, height: r.height, vid: v.id }); } catch { setReviseOverlay(null); } }, [taskVersions, flowIndex]); React.useLayoutEffect(() => { updateReviseOverlay(); }, [updateReviseOverlay, isFullscreen, compareUIOpen, revDownloadOpen]); React.useEffect(() => { // After slide changes, measure across a few frames and after a short delay let raf1 = 0, raf2 = 0, raf3 = 0; const tick = () => updateReviseOverlay(); raf1 = window.requestAnimationFrame(() => { tick(); raf2 = window.requestAnimationFrame(() => { tick(); raf3 = window.requestAnimationFrame(() => tick()); }); }); const t = window.setTimeout(tick, 260); return () => { window.cancelAnimationFrame(raf1); window.cancelAnimationFrame(raf2); window.cancelAnimationFrame(raf3); window.clearTimeout(t); }; }, [flowIndex, centerVersionId, updateReviseOverlay]); React.useEffect(() => { const onResize = () => updateReviseOverlay(); const onScroll = () => updateReviseOverlay(); window.addEventListener('resize', onResize, { passive: true } as any); window.addEventListener('scroll', onScroll, { passive: true } as any); return () => { window.removeEventListener('resize', onResize as any); window.removeEventListener('scroll', onScroll as any); }; }, [updateReviseOverlay]); const uploadDocx = async (file: File) => { setUploading(true); try { const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); const form = new FormData(); form.append('file', file); // Use getApiBase to route to correct API (tutorial-refinity or refinity) const resp = await fetch(`${base}${getApiBase('/parse')}`, { method: 'POST', body: form }); const data = await resp.json().catch(()=>({})); if (!resp.ok) throw new Error(data?.error || 'Failed to parse document'); const baseText = String(data?.text || '').trim() || `Uploaded translation by ${username}`; // Persist as new version - use getApiBase to route to correct API const resp2 = await fetch(`${base}${getApiBase(`/tasks/${encodeURIComponent(task?.id || '')}/versions`)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ originalAuthor: username, revisedBy: undefined, content: baseText }) }); const saved = await resp2.json().catch(()=>({})); if (!resp2.ok) throw new Error(saved?.error || 'Failed to save version'); const newVersion: Version = { id: saved._id, taskId: saved.taskId, originalAuthor: saved.originalAuthor, revisedBy: saved.revisedBy, versionNumber: saved.versionNumber, content: saved.content, parentVersionId: saved.parentVersionId }; setVersions(prev => [...prev, newVersion]); // Update cache try { const cacheKey = versionsCacheKey(task?.id || ''); const cached = JSON.parse(sessionStorage.getItem(cacheKey) || '[]'); const updated = [...cached, newVersion]; sessionStorage.setItem(cacheKey, JSON.stringify(updated)); } catch {} setCurrentVersionId(newVersion.id); setStage('flow'); } catch (e) { console.error('Error uploading document:', e); } finally { setUploading(false); } }; // ---------- Annotation helpers ---------- const ANNO_STORE_KEY = 'refinity_annotations_v1'; const loadAnnotations = React.useCallback((): Annotation[] => { try { const raw = localStorage.getItem(ANNO_STORE_KEY); const arr = raw ? JSON.parse(raw) : []; return Array.isArray(arr) ? arr : []; } catch { return []; } }, []); const saveAnnotations = React.useCallback((list: Annotation[]) => { try { localStorage.setItem(ANNO_STORE_KEY, JSON.stringify(list)); } catch {} }, []); const [annotations, setAnnotations] = React.useState(() => loadAnnotations()); React.useEffect(() => { saveAnnotations(annotations); }, [annotations, saveAnnotations]); const annoForVersion = React.useCallback((versionId: string) => annotations.filter(a => a.versionId === versionId), [annotations]); const addAnnotation = React.useCallback((a: Annotation) => setAnnotations(prev => [...prev, a]), []); const updateAnnotation = React.useCallback((a: Annotation) => setAnnotations(prev => prev.map(x => x.id === a.id ? a : x)), []); const deleteAnnotationById = React.useCallback((id: string) => setAnnotations(prev => prev.filter(a => a.id !== id)), []); const CATEGORY_LABELS: Record = { distortion: 'Distortion', omission: 'Unjustified omission', register: 'Inappropriate register', unidiomatic: 'Unidiomatic expression', grammar: 'Error of grammar, syntax', spelling: 'Error of spelling', punctuation: 'Error of punctuation', addition: 'Unjustified addition', other: 'Other', }; const CATEGORY_CLASS: Record = { distortion: 'bg-rose-100', omission: 'bg-amber-100', register: 'bg-indigo-100', unidiomatic: 'bg-purple-100', grammar: 'bg-blue-100', spelling: 'bg-green-100', punctuation: 'bg-teal-100', addition: 'bg-pink-100', other: 'bg-gray-100', }; function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>'); } function renderAnnotatedHtml(text: string, versionId: string, enabled: boolean): string { if (!enabled) return escapeHtml(text); const list = annoForVersion(versionId).slice().sort((a,b)=>a.start-b.start); if (!list.length) return escapeHtml(text); let html = ''; let pos = 0; for (const a of list) { const start = Math.max(0, Math.min(a.start, text.length)); const end = Math.max(start, Math.min(a.end, text.length)); if (pos < start) html += escapeHtml(text.slice(pos, start)); const label = CATEGORY_LABELS[a.category] || 'Note'; const cls = CATEGORY_CLASS[a.category] || 'bg-gray-100 ring-gray-200'; const span = escapeHtml(text.slice(start, end)) || ' '; html += `${span}`; pos = end; } if (pos < text.length) html += escapeHtml(text.slice(pos)); return html; } function rangeToOffsets(container: HTMLElement, range: Range, fullText: string): { start: number; end: number } { const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null); let start = -1; let end = -1; let charCount = 0; while (walker.nextNode()) { const node = walker.currentNode as Text; const text = node.nodeValue || ''; if (node === range.startContainer) start = charCount + range.startOffset; if (node === range.endContainer) end = charCount + range.endOffset; charCount += text.length; } if (start < 0 || end < 0) { const sel = range.toString(); const idx = fullText.indexOf(sel); if (idx >= 0) { start = idx; end = idx + sel.length; } } start = Math.max(0, Math.min(start, fullText.length)); end = Math.max(start, Math.min(end, fullText.length)); return { start, end }; } function getSelectionPopoverPosition(range: Range, container: HTMLElement): { left: number; top: number } { const rect = range.getBoundingClientRect(); const crect = container.getBoundingClientRect(); const left = Math.max(8, rect.right - crect.left + 8); const top = Math.max(8, rect.top - crect.top - 8); return { left, top }; } // Flow slide text overflow measurement for multi-line ellipsis indicator const textRefs = React.useRef<{ [id: string]: HTMLDivElement | null }>({}); const [overflowMap, setOverflowMap] = React.useState<{ [id: string]: boolean }>({}); const recomputeOverflow = React.useCallback(() => { const next: { [id: string]: boolean } = {}; try { (versions || []).forEach((v) => { const el = textRefs.current[v.id]; if (el) next[v.id] = el.scrollHeight > el.clientHeight + 1; }); } catch {} setOverflowMap(next); }, [versions]); React.useEffect(() => { recomputeOverflow(); }, [recomputeOverflow, flowIndex, isFullscreen]); React.useEffect(() => { const id = window.setTimeout(() => recomputeOverflow(), 0); return () => window.clearTimeout(id); }, [versions, flowIndex, isFullscreen, recomputeOverflow]); React.useEffect(() => { const onResize = () => recomputeOverflow(); window.addEventListener('resize', onResize); const id = window.setInterval(onResize, 600); return () => { window.removeEventListener('resize', onResize); window.clearInterval(id); }; }, [recomputeOverflow]); const uploadSourceDoc = async (file: File) => { setAddingSourceUploading(true); try { const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); const form = new FormData(); form.append('file', file); const parseEndpoint = getTutorialMode() ? '/api/tutorial-refinity/parse' : '/api/refinity/parse'; const resp = await fetch(`${base}${parseEndpoint}`, { method: 'POST', body: form }); const data = await resp.json().catch(()=>({})); if (!resp.ok) throw new Error(data?.error || 'Failed to parse document'); setNewTaskSource(String(data?.text || '')); } finally { setAddingSourceUploading(false); } }; const saveNewTask = async () => { const title = newTaskTitle.trim(); const src = newTaskSource.trim(); if (!title || !src) return; try { const body = { title, sourceText: src, createdBy: username }; if (getTutorialMode()) { // In tutorial mode, include week number const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); (body as any).weekNumber = weekNumber; } const token = localStorage.getItem('token'); const user = JSON.parse(localStorage.getItem('user') || '{}'); const viewMode = (localStorage.getItem('viewMode') || 'auto'); const effectiveRole = viewMode === 'student' ? 'student' : (user.role || 'visitor'); const headers: any = { 'Content-Type': 'application/json', 'x-user-name': username }; if (token) { headers['Authorization'] = `Bearer ${token}`; } // Always send role header headers['x-user-role'] = effectiveRole; headers['user-role'] = effectiveRole; // Also send without x- prefix for compatibility const resp = await fetch(getApiBase('/tasks'), { method: 'POST', headers, body: JSON.stringify(body) }); const saved = await resp.json().catch(()=>({})); if (!resp.ok) throw new Error(saved?.error || 'Save failed'); const t: Task = { id: saved._id, title: saved.title, sourceText: saved.sourceText, createdBy: saved.createdBy }; setTasks(prev => [...prev, t]); setSelectedTaskId(t.id); setShowAddTask(false); setNewTaskTitle(''); setNewTaskSource(''); } catch {} }; const assignRandom = async () => { try { const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); // Load tasks list - use getApiBase to route to correct API const respTasks = await fetch(`${base}${getApiBase('/tasks')}`); const tasksData: any[] = await respTasks.json().catch(()=>[]); const taskList: Task[] = Array.isArray(tasksData) ? tasksData.map(d => ({ id: d._id, title: d.title, sourceText: d.sourceText, createdBy: d.createdBy })) : []; if (!taskList.length) { setTaskStageNote('No tasks available yet.'); return; } const randTask = taskList[Math.floor(Math.random() * taskList.length)]; // Load versions for that task - use getApiBase to route to correct API const respVers = await fetch(`${base}${getApiBase(`/tasks/${encodeURIComponent(randTask.id)}/versions`)}`); const versData: any[] = await respVers.json().catch(()=>[]); const vers: Version[] = Array.isArray(versData) ? versData.map(d => ({ id: d._id, taskId: d.taskId, originalAuthor: d.originalAuthor, revisedBy: d.revisedBy, versionNumber: d.versionNumber, content: d.content, parentVersionId: d.parentVersionId })) : []; if (!vers.length) { setTaskStageNote('No versions found to revise.'); return; } // Prefer versions not by current user; fallback to any const eligible = vers.filter(v => (v.originalAuthor !== username && v.revisedBy !== username)); const pool = eligible.length ? eligible : vers; const pick = pool[Math.floor(Math.random() * pool.length)]; // Switch to that task context and open editor setSelectedTaskId(randTask.id); setVersions(vers); setCurrentVersionId(pick.id); // Ensure EditorPane receives correct initial translation by waiting one tick setTimeout(() => setStage('editor'), 0); } catch { setTaskStageNote('Random pick failed. Please try again.'); } }; const submitPastedTranslation = async () => { const text = (pastedTranslation || '').trim(); if (!text || !task?.id) return; try { const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(task.id)}/versions`), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ originalAuthor: username, revisedBy: undefined, content: text }) }); const saved = await resp.json().catch(()=>({})); if (!resp.ok) { console.error('Failed to save version:', saved?.error || 'Unknown error', saved); throw new Error(saved?.error || 'Failed to save version'); } const newVersion: Version = { id: saved._id, taskId: saved.taskId, originalAuthor: saved.originalAuthor, revisedBy: saved.revisedBy, versionNumber: saved.versionNumber, content: saved.content, parentVersionId: saved.parentVersionId }; setVersions(prev => [...prev, newVersion]); // Update cache try { const cacheKey = versionsCacheKey(task.id); const cached = JSON.parse(sessionStorage.getItem(cacheKey) || '[]'); const updated = [...cached, newVersion]; sessionStorage.setItem(cacheKey, JSON.stringify(updated)); } catch {} setPastedTranslation(''); setCurrentVersionId(newVersion.id); setStage('flow'); } catch (e) { console.error('Error saving draft translation:', e); } }; const selectManual = (id: string) => { setCurrentVersionId(id); setStage('editor'); }; const deleteTask = React.useCallback(async () => { try { if (!task?.id) return; const ok = window.confirm('Delete this Deep Revision task and all its versions? This cannot be undone.'); if (!ok) return; const headers: any = { 'x-user-name': username }; if (isAdmin) headers['x-user-role'] = 'admin'; const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(task.id)}`), { method: 'DELETE', headers }); if (!resp.ok) throw new Error('Delete failed'); setTasks(prev => prev.filter(t => t.id !== task.id)); setVersions([]); // Stay on landing; do not auto-navigate into flow for another task const remaining = tasks.filter(t => t.id !== (task?.id || '')); setSelectedTaskId(remaining.length ? remaining[0].id : ''); appliedInitialRouteRef.current = true; // prevent any hash restore from forcing stage initialRouteRef.current = null; setStage('task'); } catch {} }, [isAdmin, task?.id, tasks, username]); const handleSaveRevision = React.useCallback(async (newContent: string) => { const parent = taskVersions.find(v => v.id === currentVersionId); if (!task?.id) return; try { const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(task.id)}/versions`), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ originalAuthor: parent?.originalAuthor || username, revisedBy: username, content: newContent, parentVersionId: parent?.id, }) }); const saved = await resp.json().catch(()=>({})); if (!resp.ok) { console.error('Failed to save revision:', saved?.error || 'Unknown error', saved); throw new Error(saved?.error || 'Save failed'); } const v: Version = { id: saved._id, taskId: saved.taskId, originalAuthor: saved.originalAuthor, revisedBy: saved.revisedBy, versionNumber: saved.versionNumber, content: saved.content, parentVersionId: saved.parentVersionId, }; setVersions(prev => [...prev, v]); // Update cache try { const cacheKey = versionsCacheKey(task.id); const cached = JSON.parse(sessionStorage.getItem(cacheKey) || '[]'); const updated = [...cached, v]; sessionStorage.setItem(cacheKey, JSON.stringify(updated)); } catch {} setCurrentVersionId(v.id); // Navigate to flow to view the new version without clearing selection setStage('flow'); } catch (e) { console.error('Error saving revision:', e); // no-op; keep user in editor if needed } }, [task, taskVersions, currentVersionId, username, getApiBase, versionsCacheKey, getTutorialMode]); return (
{isFullscreen && (
)} {/* Annotation modal (Version Flow only) */} {annotationModalOpen && annotationPopover && (

{editingAnnotation ? 'Edit annotation' : 'New annotation'}