linguabot's picture
Upload folder using huggingface_hub
305f30b verified
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<Response> {
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<boolean>(() => {
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<Stage>(() => {
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<Task[]>([]);
const [selectedTaskId, setSelectedTaskId] = React.useState<string>(() => {
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<string | null>(null);
const [versions, setVersions] = React.useState<Version[]>([]);
const [currentVersionId, setCurrentVersionId] = React.useState<string | null>(() => {
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<string | null>(() => {
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<boolean>(() => {
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<boolean>(false);
const [compareA, setCompareA] = React.useState<string>('');
const [compareB, setCompareB] = React.useState<string>('');
const [compareModalOpen, setCompareModalOpen] = React.useState<boolean>(false);
const [lastDiffIds, setLastDiffIds] = React.useState<{ a?: string; b?: string }>({});
const [compareDiffHtml, setCompareDiffHtml] = React.useState<string>('');
const [compareLoading, setCompareLoading] = React.useState<boolean>(false);
const [compareDownloadOpen, setCompareDownloadOpen] = React.useState<boolean>(false);
const [compareMenuPos, setCompareMenuPos] = React.useState<{ left: number; top: number } | null>(null);
const compareBtnRef = React.useRef<HTMLButtonElement | null>(null);
const [revDownloadOpen, setRevDownloadOpen] = React.useState<boolean>(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<boolean>(false);
const appliedInitialRouteRef = React.useRef<boolean>(false);
const initialRouteRef = React.useRef<{ stage?: Stage; taskId?: string; versionId?: string; previewId?: string; fullscreen?: boolean } | null>(null);
const parseRouteHash = React.useCallback((): Record<string, string> => {
try {
const h = String(window.location.hash || '');
const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : '';
const out: Record<string, string> = {};
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<string, string> = { ...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<boolean>(true);
const [annotationPopover, setAnnotationPopover] = React.useState<{ left: number; top: number; versionId: string; range: { start: number; end: number } } | null>(null);
const [annotationModalOpen, setAnnotationModalOpen] = React.useState<boolean>(false);
const [editingAnnotation, setEditingAnnotation] = React.useState<Annotation | null>(null);
const [modalCategory, setModalCategory] = React.useState<AnnotationCategory>('distortion');
const [modalComment, setModalComment] = React.useState<string>('');
const [username] = React.useState<string>(() => {
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<boolean>(() => 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<HTMLInputElement | null>(null);
// Add Task UI state
const [showAddTask, setShowAddTask] = React.useState<boolean>(false);
const [newTaskTitle, setNewTaskTitle] = React.useState<string>('');
const [newTaskSource, setNewTaskSource] = React.useState<string>('');
const addTaskFileRef = React.useRef<HTMLInputElement | null>(null);
const [addingSourceUploading, setAddingSourceUploading] = React.useState<boolean>(false);
const [taskMenuOpen, setTaskMenuOpen] = React.useState<boolean>(false);
const [pastedTranslation, setPastedTranslation] = React.useState<string>('');
const [taskStageNote, setTaskStageNote] = React.useState<string>('');
const [showPreviewDiff, setShowPreviewDiff] = React.useState<boolean>(false);
const [inputTab, setInputTab] = React.useState<'paste' | 'upload'>('paste');
const [editingVersionId, setEditingVersionId] = React.useState<string | null>(null);
const [editingTaskOpen, setEditingTaskOpen] = React.useState<boolean>(false);
const [editTaskTitle, setEditTaskTitle] = React.useState<string>('');
const [editTaskSource, setEditTaskSource] = React.useState<string>('');
const [savingTaskEdit, setSavingTaskEdit] = React.useState<boolean>(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<number>(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<Record<string, HTMLButtonElement | null>>({});
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<Annotation[]>(() => 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<AnnotationCategory, string> = {
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<AnnotationCategory, string> = {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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)) || '&nbsp;';
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>`;
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 (
<div className={isFullscreen ? 'fixed inset-0 z-50 bg-white overflow-auto' : 'relative'}>
{isFullscreen && (
<div className="sticky top-0 z-50 flex justify-end p-3 bg-white/80 backdrop-blur">
<button onClick={() => setIsFullscreen(false)} className="px-3 py-1.5 text-sm rounded-2xl border border-gray-200 bg-white">Exit Full Screen</button>
</div>
)}
{/* Annotation modal (Version Flow only) */}
{annotationModalOpen && annotationPopover && (
<div className="fixed inset-0 z-[5000] flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">{editingAnnotation ? 'Edit annotation' : 'New annotation'}</h3>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-700 mb-1">Category <span className="text-red-500">*</span></label>
<select
value={modalCategory}
onChange={(e)=>setModalCategory(e.target.value as AnnotationCategory)}
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm"
>
{(['distortion','omission','register','unidiomatic','grammar','spelling','punctuation','addition','other'] as AnnotationCategory[]).map(k=>(
<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>
))}
</select>
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Comment (optional)</label>
<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…"
/>
</div>
</div>
<div className="mt-6 flex items-center justify-between">
{editingAnnotation && (
<button
onClick={()=>{
if (editingAnnotation) deleteAnnotationById(editingAnnotation.id);
setAnnotationModalOpen(false);
setAnnotationPopover(null);
setEditingAnnotation(null);
}}
className="px-3 py-1.5 text-sm rounded-lg text-white bg-red-600 hover:bg-red-700"
>
Delete
</button>
)}
<div className="ml-auto flex gap-2">
<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>
<button
onClick={()=>{
const versionId = annotationPopover.versionId;
if (editingAnnotation) {
const updated: Annotation = { ...editingAnnotation, category: modalCategory, comment: modalComment, updatedAt: Date.now() };
updateAnnotation(updated);
} else {
const range = annotationPopover.range;
const a: Annotation = {
id: `a_${Date.now()}_${Math.random().toString(36).slice(2,7)}`,
versionId,
start: range.start,
end: range.end,
category: modalCategory,
comment: modalComment,
createdAt: Date.now(),
updatedAt: Date.now(),
};
addAnnotation(a);
}
setAnnotationModalOpen(false);
setAnnotationPopover(null);
setEditingAnnotation(null);
}}
className="px-3 py-1.5 text-sm rounded-lg text-white bg-indigo-600 hover:bg-indigo-700"
>
Save
</button>
</div>
</div>
</div>
</div>
)}
{/* Clean container (no purple gradient) */}
<div className={isFullscreen ? 'relative mx-auto max-w-6xl p-6' : 'relative rounded-xl p-6 bg-transparent'}>
{/* Stage 1: Task Creation / Selection */}
{stage === 'task' && (
<div>
<div className="mb-2" />
{/* Task selector (full width) */}
<div className="mb-6">
<label className="block text-sm text-gray-700 mb-1">Task</label>
<div className="relative inline-block w-full md:w-80">
<button type="button" onClick={()=>setTaskMenuOpen(o=>!o)} className="relative w-full text-left inline-flex items-center justify-between px-3 py-1.5 rounded-2xl border border-gray-200 ring-1 ring-inset ring-gray-200 backdrop-blur-md bg-white/30 text-sm">
<span className="truncate text-gray-900">{tasks.find(t=>t.id===selectedTaskId)?.title || 'Select a task'}</span>
<span className="ml-2 text-gray-700"></span>
</button>
{taskMenuOpen && (
<div className="absolute z-20 mt-2 w-full">
<div className="relative rounded-2xl bg-white/10 backdrop-blur-md ring-1 ring-inset ring-gray-200 border border-gray-200 shadow-lg overflow-hidden">
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-50 [background:linear-gradient(to_bottom,rgba(255,255,255,0.3),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.28),rgba(255,255,255,0)_28%)]" />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-45" />
<ul className="relative max-h-64 overflow-auto text-sm">
{tasks.map(t => (
<li key={t.id}>
<button type="button" onClick={()=>{
userSelectedTaskIdRef.current = t.id;
setSelectedTaskId(t.id);
setTaskMenuOpen(false);
// Persist user selection to localStorage in tutorial mode
try {
const isTutorial = localStorage.getItem('refinityMode') === 'tutorial';
if (isTutorial) {
const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0');
if (weekNumber > 0) {
localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, t.id);
}
}
} catch {}
}} className={`w-full text-left px-3 py-1.5 text-gray-900 hover:bg-white/30 ${selectedTaskId===t.id ? 'bg-indigo-600/20' : ''}`}>{t.title}</button>
</li>
))}
{tasks.length===0 && (
<li className="px-3 py-2 text-sm text-gray-600">No tasks yet</li>
)}
</ul>
</div>
</div>
)}
</div>
</div>
{/* Unified input area: tabs + content in one container */}
<div className="mb-6 rounded-2xl border border-gray-200 bg-white/60 backdrop-blur">
<div className="px-3 py-2 border-b border-gray-200">
<div className="inline-flex rounded-xl overflow-hidden ring-1 ring-gray-200">
<button onClick={()=>setInputTab('paste')} className={`px-3 py-1.5 text-sm ${inputTab==='paste' ? 'bg-white text-gray-900' : 'bg-gray-100 text-gray-600'}`}>Paste Text</button>
<button onClick={()=>setInputTab('upload')} className={`px-3 py-1.5 text-sm ${inputTab==='upload' ? 'bg-white text-gray-900' : 'bg-gray-100 text-gray-600'}`}>Upload Document</button>
</div>
</div>
<div className="p-4" style={{ minHeight: '210px' }}>
{inputTab==='paste' ? (
<div>
<textarea value={pastedTranslation} onChange={(e)=>setPastedTranslation(e.target.value)} rows={4} className="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" placeholder="Paste translation text here…" />
<div className="mt-2">
<button onClick={submitPastedTranslation} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 py-1.5 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-slate-800 hover:bg-slate-900 active:translate-y-0.5 transition-all duration-200">Use pasted text</button>
</div>
</div>
) : (
<div>
<input ref={fileInputRef} type="file" accept=".doc,.docx" onChange={(e)=>{ const f=e.target.files?.[0]; if(f) uploadDocx(f); if(fileInputRef.current) fileInputRef.current.value=''; }} className="hidden" />
<button type="button" onClick={()=>fileInputRef.current?.click()} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 py-1.5 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-slate-800 hover:bg-slate-900 active:translate-y-0.5 transition-all duration-200">
<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%)]" />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
<span className="relative z-10">Choose file</span>
</button>
{uploading && <span className="ml-3 text-sm text-gray-600">Uploading…</span>}
</div>
)}
</div>
</div>
<div className="mt-8 flex gap-3 items-center flex-wrap">
<button
onClick={()=>{
if (taskVersions.length > 0) {
setStage('flow');
} else {
// In tutorial mode, admin should have created the first version
const isTut = getTutorialMode();
if (isTut) {
setTaskStageNote('No versions found. Please contact admin to create the first version.');
} else {
setTaskStageNote('No versions found to start.');
}
}
}}
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-violet-600/70 hover:bg-violet-700 active:translate-y-0.5 transition-all duration-200"
>
<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%)]" />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
<span className="relative z-10">Start</span>
</button>
{(!getTutorialMode() || isAdmin) && (
<button onClick={()=>setShowAddTask(v=>!v)} className="relative inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-violet-700 ring-1 ring-inset ring-violet-300 bg-white/20 backdrop-blur-md hover:bg-violet-50 active:translate-y-0.5 transition-all duration-200">
<span className="relative z-10">New Task</span>
</button>
)}
{(isAdmin || String(username).toLowerCase()===String(tasks.find(t=>t.id===selectedTaskId)?.createdBy||'').toLowerCase()) && task?.id && (
<div className="flex items-center gap-2">
<button onClick={openEditTask} className="relative inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-gray-800 ring-1 ring-inset ring-gray-300 bg-white/20 backdrop-blur-md hover:bg-gray-100 active:translate-y-0.5 transition-all duration-200">
<span className="relative z-10">Edit Task</span>
</button>
<button onClick={deleteTask} className="relative inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-red-700 ring-1 ring-inset ring-red-300 bg-white/20 backdrop-blur-md hover:bg-red-600 hover:text-white active:translate-y-0.5 transition-all duration-200">
<span className="relative z-10">Delete Task</span>
</button>
</div>
)}
{taskStageNote && <span className="text-sm text-gray-600 ml-2">{taskStageNote}</span>}
</div>
{showAddTask && (
<div className="mt-6 relative rounded-xl">
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-indigo-200/45 via-indigo-100/40 to-indigo-300/45" />
<div className="relative rounded-xl bg-white/10 backdrop-blur-md ring-1 ring-inset ring-white/30 shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)] p-4">
<div className="pointer-events-none absolute inset-0 rounded-xl opacity-50 [background:linear-gradient(to_bottom,rgba(255,255,255,0.3),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.28),rgba(255,255,255,0)_28%)]" />
<div className="pointer-events-none absolute inset-0 rounded-xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-45" />
<div className="relative grid grid-cols-1 md:grid-cols-3 gap-4 items-start">
<div>
<label className="block text-sm text-gray-700 mb-1">Title</label>
<input value={newTaskTitle} onChange={(e)=>setNewTaskTitle(e.target.value)} className="w-full px-3 py-2 rounded-lg bg-white/50 backdrop-blur border border-ui-border text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
</div>
<div className="md:col-span-2">
<label className="block text-sm text-gray-700 mb-1">Source Text</label>
<textarea value={newTaskSource} onChange={(e)=>setNewTaskSource(e.target.value)} rows={4} className="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" placeholder="Paste source text here…" />
<div className="mt-2 flex items-center gap-3">
<input ref={addTaskFileRef} type="file" accept=".doc,.docx" onChange={(e)=>{ const f=e.target.files?.[0]; if(f) uploadSourceDoc(f); if(addTaskFileRef.current) addTaskFileRef.current.value=''; }} className="hidden" />
<button type="button" onClick={()=>addTaskFileRef.current?.click()} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 py-1.5 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 active:translate-y-0.5 transition-all duration-200">
<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%)]" />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
<span className="relative z-10">Choose source file</span>
</button>
{addingSourceUploading && <span className="text-sm text-gray-600">Parsing…</span>}
</div>
</div>
</div>
<div className="relative mt-4 flex gap-3">
<button onClick={saveNewTask} 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 active:translate-y-0.5 transition-all duration-200">Save Task</button>
<button onClick={()=>{ setShowAddTask(false); setNewTaskTitle(''); setNewTaskSource(''); }} 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">Cancel</button>
</div>
</div>
</div>
)}
</div>
)}
{/* Simple Edit Task Modal */}
{editingTaskOpen && (
<div className="fixed inset-0 z-[5000] flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Edit Task</h3>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-700 mb-1">Title</label>
<input value={editTaskTitle} onChange={e=>setEditTaskTitle(e.target.value)} className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Source Text</label>
<textarea value={editTaskSource} onChange={e=>setEditTaskSource(e.target.value)} className="w-full border border-gray-300 rounded-lg px-3 py-2 min-h-[200px] focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-3">
<button onClick={()=>setEditingTaskOpen(false)} className="px-4 py-2 text-sm rounded-lg border border-gray-300 text-gray-700 bg-white hover:bg-gray-50">Cancel</button>
<button onClick={saveTaskEdit} disabled={savingTaskEdit} className="px-4 py-2 text-sm rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60">{savingTaskEdit ? 'Saving...' : 'Save'}</button>
</div>
</div>
</div>
)}
{/* Stage 2: Version Flow - Swiper Coverflow */}
{stage === 'flow' && (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-xl font-semibold text-black">Version Flow — {task?.title}</h3>
<div className="text-gray-700 text-sm">Swipe/click to browse. Center slide is active.</div>
</div>
<div className="flex gap-2">
<button onClick={()=>{ setIsFullscreen(false); setStage('task'); }} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 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">
<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%)]" />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
<span className="relative z-10">Back</span>
</button>
<button onClick={()=>setIsFullscreen(v=>!v)} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 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">
<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%)]" />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
<span className="relative z-10">{isFullscreen ? 'Windowed' : 'Full Screen'}</span>
</button>
</div>
</div>
<div className={`relative min-h-0 ${isFullscreen ? 'pt-1 pb-2' : 'pt-2 pb-6'}`} style={{ maxHeight: (compareUIOpen || revDownloadOpen) ? 'none' : (isFullscreen ? 'calc(100vh - 80px)' : 'calc(100vh - 160px)') }}>
<style>{`
.mySwiper .swiper-pagination { z-index: 5; pointer-events: none; }
.mySwiper .swiper-pagination-bullet { pointer-events: auto; }
.mySwiper .swiper-slide { z-index: 1; }
.mySwiper .swiper-slide-active { z-index: 1000; }
.mySwiper .swiper-slide-next, .mySwiper .swiper-slide-prev { z-index: 500; }
`}</style>
<SwiperRoot
modules={[EffectCoverflow, Navigation, Pagination, A11y]}
effect="coverflow"
onSlideChange={(s: SwiperInstance)=> setFlowIndex(s.activeIndex)}
onAfterInit={(s: SwiperInstance)=> setFlowIndex(s.activeIndex)}
onClickCapture={(e: any)=>{ /* keep default; no slide-level click */ }}
navigation
pagination={{ clickable: true }}
centeredSlides
// fixed height so cards fit viewport and never overflow page
slidesPerView={'auto'}
spaceBetween={32}
preventClicks={false}
preventClicksPropagation={false}
noSwiping={true}
noSwipingClass={'swiper-no-swiping'}
breakpoints={{
640: { spaceBetween: 36 },
1024: { spaceBetween: 40 },
1440: { spaceBetween: 48 }
}}
observer
observeParents
allowTouchMove={false}
coverflowEffect={{ rotate: 0, stretch: 0, depth: 80, modifier: 1, slideShadows: false }}
className="mySwiper !px-4 overflow-visible pb-2"
style={{ height: isFullscreen ? 'calc(100vh - 96px)' : 'min(76vh, calc(100vh - 220px))', minHeight: '340px' }}
>
{taskVersions.map((v, idx) => {
const isCenter = idx === flowIndex;
return (
<SwiperSlide key={v.id} style={{ width: 'clamp(320px, 48vw, 720px)', height: isFullscreen ? '72vh' : '62.4vh', zIndex: (idx === flowIndex ? 1000 : (Math.abs(idx - flowIndex) === 1 ? 500 : 1)) }}>
<div
className={`relative h-full rounded-2xl p-6 bg-white ring-1 ring-gray-200 shadow-xl text-base overflow-hidden flex flex-col ${isCenter ? 'z-[60]' : 'opacity-95 z-0'}`}
role="button"
onClick={(e)=>{ e.preventDefault(); setPreviewVersionId(v.id); setShowPreviewDiff(false); setStage('preview'); }}
>
<div className={`ref-card-overlay pointer-events-none absolute inset-0 rounded-2xl opacity-40 [background:linear-gradient(to_bottom,rgba(255,255,255,0.45),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]`} />
<div className={`ref-card-overlay pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-30`} />
<div className="text-gray-800 text-sm mb-2">Original: {v.originalAuthor}</div>
<div className="text-gray-600 text-xs mb-3">Revised by: {v.revisedBy ? `${v.revisedBy} (v${v.versionNumber})` : `— (v${v.versionNumber})`}</div>
<div
ref={(el)=>{ textRefs.current[v.id]=el; }}
className={`text-gray-900 whitespace-pre-wrap break-words leading-relaxed flex-1 overflow-hidden pr-1 relative`}
style={{ maxHeight: 'calc(100% - 10.5rem)', paddingBottom: '0.25rem' }}
>
{v.content || ''}
</div>
{overflowMap[v.id] && (
<div className="pointer-events-none absolute text-gray-700" style={{ left: '1.5rem', bottom: '6rem', zIndex: 2000 }}></div>
)}
{isCenter && (
<div
className="action-row absolute left-6 right-6 bottom-6 swiper-no-swiping"
data-version-number={v.versionNumber}
data-version-index={idx}
data-version-id={v.id}
data-owner={String(v.revisedBy || v.originalAuthor || '').toLowerCase() === String(username).toLowerCase() || !!isAdmin}
onClickCapture={(e)=> {
// Diagnostic: capture clicks within action row to ensure they are not swallowed by slide
const t = e.target as HTMLElement;
console.debug('[VF] action-row click(capture)', {
idx,
versionId: v.id,
buttonData: t?.dataset || {},
targetTag: t?.tagName,
targetClasses: t?.className
});
}}
onMouseEnter={()=>{ updateReviseOverlay(); }}
onMouseMove={()=>{ updateReviseOverlay(); }}
style={{ pointerEvents: 'auto', transform: 'translateZ(0)', zIndex: 3000 }}
>
{/* Invisible first child to bypass first-child quirks while matching pill styles (kept in place) */}
<button
type="button"
aria-hidden="true"
tabIndex={-1}
className="inline-flex items-center justify-center gap-2 text-white text-sm font-medium rounded-2xl ring-1 ring-inset ring-white/50"
style={{ opacity: 0, pointerEvents: 'none', position: 'absolute', left: 0, top: 0, padding: '0.5rem 0.75rem', backgroundColor: '#4f46e5', borderRadius: '1rem', border: '1px solid rgba(255,255,255,0.5)' }}
/>
<div className="flex justify-center gap-3 w-full">
<button
type="button"
data-btn="revise"
ref={(el)=>{ reviseBtnRefs.current[v.id] = el; }}
onClickCapture={(e)=>{ e.stopPropagation(); }}
onPointerDown={(e)=>{ e.stopPropagation(); }}
onPointerUp={(e)=>{ e.stopPropagation(); }}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.debug('[VF] Revise click', { idx, versionId: v.id, versionNumber: v.versionNumber });
selectManual(v.id);
}}
onMouseEnter={(e) => {
// Diagnostic: inspect computed styles which could affect clickability
try {
const el = e.currentTarget as HTMLElement;
const elCS = window.getComputedStyle(el);
const parent = el.parentElement;
const parentCS = parent ? window.getComputedStyle(parent) : null;
const row = el.closest('.action-row') as HTMLElement | null;
const rowCS = row ? window.getComputedStyle(row) : null;
console.debug('[VF] Revise hover styles', {
idx,
versionId: v.id,
el: { zIndex: elCS.zIndex, pointerEvents: elCS.pointerEvents, position: elCS.position },
parent: parentCS ? { zIndex: parentCS.zIndex, pointerEvents: parentCS.pointerEvents, position: parentCS.position } : null,
row: rowCS ? { zIndex: rowCS.zIndex, pointerEvents: rowCS.pointerEvents, position: rowCS.position } : null
});
} catch {}
}}
className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 py-1.5 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-violet-600/70 hover:bg-violet-700 active:translate-y-0.5 transition-all duration-200"
style={{ position: 'relative', zIndex: 3100, pointerEvents: 'auto', cursor: 'pointer', touchAction: 'manipulation' }}
>
<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%)]" />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
Revise
</button>
{(String(v.revisedBy || v.originalAuthor || '').toLowerCase() === String(username).toLowerCase() || isAdmin) && (
<button
type="button"
data-btn="edit"
onClick={(e)=>{ e.preventDefault(); e.stopPropagation(); console.debug('[VF] Edit click', { idx, versionId: v.id }); setCurrentVersionId(v.id); setEditingVersionId(v.id); setStage('editor'); }}
className="relative inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium rounded-2xl text-gray-800 ring-1 ring-inset ring-gray-300 bg-white/20 backdrop-blur-md hover:bg-gray-100 active:translate-y-0.5 transition-all duration-200"
style={{ position: 'relative', zIndex: 3100, pointerEvents: 'auto', cursor: 'pointer', touchAction: 'manipulation' }}
>
Edit
</button>
)}
<button
type="button"
data-btn="download"
onClick={(e)=>{
e.preventDefault(); e.stopPropagation();
// Export single version content as plain .docx (same as Editor “Without Annotations”)
(async()=>{
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const currentUser = username || 'User';
const filename = `${toSafeName(task?.title||'Task')}_${yyyymmdd_hhmm()}_${toSafeName(currentUser)}.docx`;
const body = { current: v.content || '', 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 inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium rounded-2xl text-gray-800 ring-1 ring-inset ring-gray-300 bg-white/20 backdrop-blur-md hover:bg-gray-100 active:translate-y-0.5 transition-all duration-200"
style={{ position: 'relative', zIndex: 3100, pointerEvents: 'auto', cursor: 'pointer', touchAction: 'manipulation' }}
>
Download
</button>
<button
type="button"
data-btn="compare"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); console.debug('[VF] Compare click', { idx, versionId: v.id }); setCompareUIOpen(true); if (!compareA) setCompareA(v.id); }}
className="relative inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium rounded-2xl text-gray-800 ring-1 ring-inset ring-gray-300 bg-white/20 backdrop-blur-md hover:bg-gray-100 active:translate-y-0.5 transition-all duration-200"
style={{ position: 'relative', zIndex: 3100, pointerEvents: 'auto', cursor: 'pointer', touchAction: 'manipulation' }}
>
Compare
</button>
{(isAdmin || String(v.revisedBy || v.originalAuthor || '').toLowerCase() === String(username).toLowerCase()) && (
<button
type="button"
data-btn="delete"
onClick={(e)=>{ e.preventDefault(); e.stopPropagation(); console.debug('[VF] Delete click', { idx, versionId: v.id }); deleteVersion(v.id); }}
className="relative inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium rounded-2xl text-red-700 ring-1 ring-inset ring-red-300 bg-white/20 backdrop-blur-md hover:bg-red-600 hover:text-white active:translate-y-0.5 transition-all duration-200"
style={{ position: 'relative', zIndex: 3100, pointerEvents: 'auto', cursor: 'pointer', touchAction: 'manipulation' }}
>
Delete
</button>
)}
</div>
</div>
)}
</div>
</SwiperSlide>
);
})}
</SwiperRoot>
{reviseOverlay && createPortal(
<button
aria-label="Revise overlay"
style={{
position: 'fixed',
left: `${reviseOverlay.left}px`,
top: `${reviseOverlay.top}px`,
width: `${reviseOverlay.width}px`,
height: `${reviseOverlay.height}px`,
background: 'transparent',
border: 'none',
padding: 0,
margin: 0,
cursor: 'pointer',
zIndex: 2147483000
}}
onClick={(e)=>{ e.preventDefault(); e.stopPropagation(); selectManual(reviseOverlay.vid); }}
onPointerDown={(e)=>{ e.stopPropagation(); }}
onPointerUp={(e)=>{ e.stopPropagation(); }}
/>,
document.body
)}
{/* External V2 Revise Button removed per user request */}
{compareUIOpen && (
<div className="mt-5 p-3 rounded-2xl border border-gray-200 bg-white/60 backdrop-blur" style={{ position: 'relative' }}>
<div className="flex flex-col md:flex-row gap-3 md:items-end">
<div className="flex-1">
<label className="block text-sm text-gray-700 mb-1">Version A (previous)</label>
<select value={compareA} onChange={(e)=>setCompareA(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm">
<option value="">Select version…</option>
{taskVersions.map(v => (
<option key={v.id} value={v.id}>{`v${v.versionNumber} — ${v.revisedBy || v.originalAuthor}`}</option>
))}
</select>
</div>
<div className="flex-1">
<label className="block text-sm text-gray-700 mb-1">Version B (current)</label>
<select value={compareB} onChange={(e)=>setCompareB(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm">
<option value="">Select version…</option>
{taskVersions.map(v => (
<option key={v.id} value={v.id}>{`v${v.versionNumber} — ${v.revisedBy || v.originalAuthor}`}</option>
))}
</select>
</div>
<div className="flex gap-2 overflow-visible items-start">
<button onClick={()=>{ const tmp = compareA; setCompareA(compareB); setCompareB(tmp); }} disabled={!compareA && !compareB} className="px-3 py-2 text-sm rounded-md border border-gray-300 bg-white">Swap</button>
<button onClick={async()=>{
const a = taskVersions.find(v=>v.id===compareA);
const b = taskVersions.find(v=>v.id===compareB);
if (!a || !b || a.id===b.id) return;
try {
setCompareLoading(true);
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const resp = await fetch(`${base}/api/refinity/diff`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prev: a.content || '', current: b.content || '' }) });
const data = await resp.json().catch(()=>({}));
if (!resp.ok) throw new Error(data?.error || 'Diff failed');
setCompareDiffHtml(String(data?.html || ''));
setCompareModalOpen(true);
setLastDiffIds({ a: a.id, b: b.id });
} catch {}
finally { setCompareLoading(false); }
}} disabled={!compareA || !compareB || compareA===compareB || compareLoading} className="px-3 py-2 text-sm rounded-md border border-gray-300 bg-white disabled:opacity-50">{compareLoading ? 'Computing…' : 'Show Diff'}</button>
<div className="relative">
<button ref={compareBtnRef} onClick={(e)=>{ e.preventDefault(); const r=(e.currentTarget as HTMLElement).getBoundingClientRect(); setCompareMenuPos({ left: r.left, top: r.bottom + 8 }); setCompareDownloadOpen(v=>!v); }} disabled={!compareA || !compareB || compareA===compareB} className="px-3 py-2 text-sm rounded-md border border-gray-300 bg-white">Download ▾</button>
{compareDownloadOpen && compareMenuPos && createPortal(
<div style={{ position: 'fixed', left: compareMenuPos.left, top: compareMenuPos.top, zIndex: 10000 }} className="w-52 rounded-md border border-gray-200 bg-white shadow-lg text-left">
<button onClick={async()=>{
setCompareDownloadOpen(false);
const a = taskVersions.find(v=>v.id===compareA);
const b = taskVersions.find(v=>v.id===compareB);
if (!a || !b || a.id===b.id) return;
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const older = (a.versionNumber <= b.versionNumber) ? a : b;
const newer = (a.versionNumber <= b.versionNumber) ? b : a;
const latestReviser = (newer.revisedBy || newer.originalAuthor || username || 'User');
const A = older.versionNumber, B = newer.versionNumber;
const filename = `${toSafeName(task?.title||'Task')}_Compare-v${A}-v${B}_Inline_${yyyymmdd_hhmm()}_${toSafeName(latestReviser)}.docx`;
const body = { prev: older.content||'', current: newer.content||'', filename, authorName: latestReviser };
let resp = await fetch(`${base}/api/refinity/track-changes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!resp.ok) {
// Fallback to plain export of current text
resp = await fetch(`${base}/api/refinity/export-plain`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current: b.content||'', filename }) });
}
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">Inline Changes</button>
<button onClick={async()=>{
setCompareDownloadOpen(false);
const a = taskVersions.find(v=>v.id===compareA);
const b = taskVersions.find(v=>v.id===compareB);
if (!a || !b || a.id===b.id) return;
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const older = (a.versionNumber <= b.versionNumber) ? a : b;
const newer = (a.versionNumber <= b.versionNumber) ? b : a;
const latestReviser = (newer.revisedBy || newer.originalAuthor || username || 'User');
const A = older.versionNumber, B = newer.versionNumber;
const filename = `${toSafeName(task?.title||'Task')}_Compare-v${A}-v${B}_Comments_${yyyymmdd_hhmm()}_${toSafeName(latestReviser)}.docx`;
const body = { prev: older.content||'', current: newer.content||'', filename, authorName: latestReviser, includeComments: false };
let resp = await fetch(`${base}/api/refinity/track-changes-ooxml`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!resp.ok) {
// Fallback to inline; then to plain as last resort
resp = await fetch(`${base}/api/refinity/track-changes-ooxml`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...body, includeComments: false }) });
if (!resp.ok) {
resp = await fetch(`${base}/api/refinity/export-plain`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current: b.content||'', filename }) });
}
}
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">Tracked Changes</button>
<button onClick={async()=>{
setCompareDownloadOpen(false);
const a = taskVersions.find(v=>v.id===compareA);
const b = taskVersions.find(v=>v.id===compareB);
if (!a || !b || a.id===b.id) return;
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/,'');
const older = (a.versionNumber <= b.versionNumber) ? a : b;
const newer = (a.versionNumber <= b.versionNumber) ? b : a;
const latestReviser = (newer.revisedBy || newer.originalAuthor || username || 'User');
const A = older.versionNumber, B = newer.versionNumber;
const filename = `${toSafeName(task?.title||'Task')}_Compare-v${A}-v${B}_CommentsSidebar_${yyyymmdd_hhmm()}_${toSafeName(latestReviser)}.docx`;
const body: any = {
prev: older.content || '',
current: newer.content || '',
filename,
authorName: latestReviser,
annotationVersionId: older.id,
};
let resp = await fetch(getApiBase('/compare-comments-with-corrections'), {
method: 'POST',
headers: (() => {
const user = JSON.parse(localStorage.getItem('user') || '{}');
const headers: any = { 'Content-Type': 'application/json' };
if (user.name) headers['x-user-name'] = user.name;
if (user.email) headers['x-user-email'] = user.email;
if (user.role) {
headers['x-user-role'] = user.role;
headers['user-role'] = user.role;
}
return headers;
})(),
body: JSON.stringify(body),
});
if (!resp.ok) {
// Fallback to plain export of older text
resp = await fetch(getApiBase('/export-plain'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current: older.content || '', filename }),
});
}
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">Sidebar Comments</button>
</div>, document.body
)}
</div>
<button onClick={()=>{ setCompareUIOpen(false); setCompareA(''); setCompareB(''); }} className="px-3 py-2 text-sm rounded-md border border-gray-300 bg-white">Close</button>
</div>
</div>
</div>
)}
{compareUIOpen && (<div aria-hidden="true" className="h-10" />)}
<style dangerouslySetInnerHTML={{ __html: `
.mySwiper .swiper-wrapper { align-items: center; }
.mySwiper .swiper-button-prev, .mySwiper .swiper-button-next { top: 50% !important; transform: translateY(-50%); }
.mySwiper .swiper-button-prev:after, .mySwiper .swiper-button-next:after { font-size: 18px; color: #94a3b8; }
.mySwiper .swiper-pagination { bottom: 6px; }
/* Chrome-only: avoid half-line clipping at the bottom of slides */
.is-chrome .mySwiper .swiper-slide { padding-bottom: 6px; }
.is-chrome .mySwiper .swiper-slide .whitespace-pre-wrap { padding-bottom: 6px; }
/* Ensure buttons inside slides are clickable */
.mySwiper .swiper-slide button, .mySwiper .swiper-slide .action-row > * {
pointer-events: auto !important;
position: relative;
z-index: 9999 !important;
transform: translateZ(0) !important;
-webkit-transform: translateZ(0) !important;
}
.mySwiper .swiper-slide button:hover, .mySwiper .swiper-slide .action-row > *:hover { transform: translateY(-0.5px) translateZ(0) !important; }
/* Force button clickability on all slides */
.mySwiper .swiper-slide-active button,
.mySwiper .swiper-slide button,
.mySwiper .swiper-slide .action-row > * {
pointer-events: auto !important;
z-index: 9999 !important;
}
/* Ensure first child in action row is clickable */
.mySwiper .swiper-slide .action-row > *:first-child {
pointer-events: auto !important;
z-index: 99999 !important;
position: relative !important;
transform: translateZ(0) !important;
-webkit-transform: translateZ(0) !important;
}
` }} />
</div>
{compareModalOpen && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" role="dialog" aria-modal="true" key="compare-modal">
<div className="relative max-w-5xl w-full max-h-[85vh] rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200 p-0 overflow-hidden">
<div className="flex items-center justify-between px-6 pt-5 pb-3 border-b sticky top-0 z-10 bg-white">
<div className="text-gray-800 font-medium">Diff</div>
<div className="flex items-center gap-2">
<button onClick={async()=>{
try {
const a = taskVersions.find(v=>v.id===lastDiffIds.a || v.id===compareA);
const b = taskVersions.find(v=>v.id===lastDiffIds.b || v.id===compareB);
if (!a || !b || a.id===b.id) return;
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const older = (a.versionNumber <= b.versionNumber) ? a : b;
const newer = (a.versionNumber <= b.versionNumber) ? b : a;
const latestReviser = (newer?.revisedBy || newer?.originalAuthor || username || 'User');
const A = older.versionNumber, B = newer.versionNumber;
const filename = `${toSafeName(task?.title||'Task')}_Compare-v${A}-v${B}_Inline_${yyyymmdd_hhmm()}_${toSafeName(latestReviser)}.docx`;
const body = { prev: (older.content||''), current: (newer.content||''), filename, authorName: latestReviser };
const resp = await fetch(`${base}/api/refinity/track-changes`, { 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="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white">Export (Inline Changes)</button>
<button onClick={async()=>{
try {
const a = taskVersions.find(v=>v.id===lastDiffIds.a || v.id===compareA);
const b = taskVersions.find(v=>v.id===lastDiffIds.b || v.id===compareB);
if (!a || !b || a.id===b.id) return;
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const older = (a.versionNumber <= b.versionNumber) ? a : b;
const newer = (a.versionNumber <= b.versionNumber) ? b : a;
const latestReviser = (newer?.revisedBy || newer?.originalAuthor || username || 'User');
const A = older.versionNumber, B = newer.versionNumber;
const filename = `${toSafeName(task?.title||'Task')}_Compare-v${A}-v${B}_Comments_${yyyymmdd_hhmm()}_${toSafeName(latestReviser)}.docx`;
const body = { prev: (older.content||''), current: (newer.content||''), filename, authorName: latestReviser, includeComments: false };
const resp = await fetch(`${base}/api/refinity/track-changes-ooxml`, { 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="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white">Export (Tracked Changes)</button>
<button onClick={async()=>{
try {
const a = taskVersions.find(v=>v.id===lastDiffIds.a || v.id===compareA);
const b = taskVersions.find(v=>v.id===lastDiffIds.b || v.id===compareB);
if (!a || !b || a.id===b.id) return;
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/,'');
const older = (a.versionNumber <= b.versionNumber) ? a : b;
const newer = (a.versionNumber <= b.versionNumber) ? b : a;
const latestReviser = (newer?.revisedBy || newer?.originalAuthor || username || 'User');
const A = older.versionNumber, B = newer.versionNumber;
const filename = `${toSafeName(task?.title||'Task')}_Compare-v${A}-v${B}_SidebarComments_${yyyymmdd_hhmm()}_${toSafeName(latestReviser)}.docx`;
const body: any = {
prev: older.content || '',
current: newer.content || '',
filename,
authorName: latestReviser,
annotationVersionId: older.id,
};
let resp = await fetch(getApiBase('/compare-comments-with-corrections'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
// Fallback to plain export of older text
resp = await fetch(getApiBase('/export-plain'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current: older.content || '', filename }),
});
}
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="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white">Export (Sidebar Comments)</button>
<button onClick={()=>setCompareModalOpen(false)} className="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white">Close</button>
</div>
</div>
<div className="px-6 pb-6 overflow-auto max-h-[70vh]">
<div className="prose prose-sm max-w-none text-gray-900 whitespace-pre-wrap break-words" dangerouslySetInnerHTML={{ __html: compareDiffHtml }} />
</div>
</div>
</div>
)}
</div>
)}
{/* Stage: Preview full text */}
{stage === 'preview' && (
<>
{!previewVersion && (
<div className="rounded-xl ring-1 ring-gray-200 shadow-sm overflow-hidden bg-white/60 min-h-[280px] animate-pulse" />
)}
{previewVersion && (
<PreviewPane
version={previewVersion || (flowIndex >= 0 ? (taskVersions[flowIndex] || null) : null)}
onBack={()=>setStage('flow')}
onEdit={()=>{ if (previewVersionId) { setCurrentVersionId(previewVersionId); setStage('editor'); } }}
compareInitially={showPreviewDiff}
/>
)}
</>
)}
{/* Stage 3: Editor */}
{stage === 'editor' && (
<>
{(!currentVersion || !task?.sourceText) && (
<div className="rounded-xl ring-1 ring-gray-200 shadow-sm overflow-hidden bg-white/60 min-h-[520px] animate-pulse" />
)}
{currentVersion && task?.sourceText && (
<EditorPane
source={task?.sourceText || ''}
initialTranslation={currentVersion?.content || ''}
getApiBase={getApiBase}
onBack={()=>setStage('flow')}
onSave={handleSaveRevision}
onSaveEdit={editingVersionId ? ((text)=>saveEditedVersion(editingVersionId, text)) : undefined}
taskTitle={task?.title || 'Task'}
username={username}
nextVersionNumber={((versions.filter(v=>v.taskId===(task?.id||'')).slice(-1)[0]?.versionNumber) || 0) + 1}
isFullscreen={isFullscreen}
onToggleFullscreen={()=>setIsFullscreen(v=>!v)}
versionId={currentVersionId || ''}
/>
)}
</>
)}
</div>
</div>
);
};
const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack: ()=>void; onSave: (text: string)=>void; onSaveEdit?: (text: string)=>void; taskTitle: string; username: string; nextVersionNumber: number; isFullscreen: boolean; onToggleFullscreen: ()=>void; versionId: string; getApiBase: (endpoint: string) => string }>=({ source, initialTranslation, onBack, onSave, onSaveEdit, taskTitle, username, nextVersionNumber, isFullscreen, onToggleFullscreen, versionId, getApiBase })=>{
const [text, setText] = React.useState<string>(initialTranslation);
const [saving, setSaving] = React.useState(false);
// Sync text with incoming props when version/context changes (e.g., refresh -> data loads)
React.useEffect(() => {
setText(initialTranslation || '');
}, [versionId, initialTranslation]);
const [diffHtml, setDiffHtml] = React.useState<string>('');
const [showDiff, setShowDiff] = React.useState<boolean>(false);
const [revDownloadOpen, setRevDownloadOpen] = React.useState<boolean>(false);
const [revMenuPos, setRevMenuPos] = React.useState<{ left: number; top: number } | null>(null);
const [diffDownloadOpen, setDiffDownloadOpen] = React.useState<boolean>(false);
const [diffMenuPos, setDiffMenuPos] = React.useState<{ left: number; top: number } | null>(null);
const revBtnRef = React.useRef<HTMLButtonElement | null>(null);
const diffBtnRef = React.useRef<HTMLButtonElement | null>(null);
const sourceRef = React.useRef<HTMLDivElement>(null);
const [textareaHeight, setTextareaHeight] = React.useState('420px');
const [commentsOpen, setCommentsOpen] = React.useState(false);
const [newComment, setNewComment] = React.useState('');
const [comments, setComments] = React.useState<Array<{ id: string; text: string; ts: number }>>([]);
const taRef = React.useRef<HTMLTextAreaElement | null>(null);
const overlayRef = React.useRef<HTMLDivElement | null>(null);
const wrapperRef = React.useRef<HTMLDivElement | null>(null);
const annotatedRef = React.useRef<HTMLDivElement | null>(null);
// Annotation layer state (revision mode only)
const [showAnnotations, setShowAnnotations] = React.useState<boolean>(true);
const [toastMsg, setToastMsg] = React.useState<string>('');
const [dirty, setDirty] = React.useState<boolean>(false);
React.useEffect(() => { setDirty(false); }, [versionId, initialTranslation]);
React.useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (dirty) { e.preventDefault(); e.returnValue = ''; }
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [dirty]);
const showToast = (msg: string) => { setToastMsg(msg); setTimeout(()=>setToastMsg(''), 1800); };
// Tutorial admin: choose which user's highlights to display to avoid clashes.
const ADMIN_ANNO_VIEW_CLEAN = '__clean__';
const ADMIN_ANNO_VIEW_EDIT = '__edit__';
const ADMIN_ANNO_VIEW_LEGACY = '__legacy__';
const [adminAnnoView, setAdminAnnoView] = React.useState<string>(ADMIN_ANNO_VIEW_CLEAN);
const [annoCreators, setAnnoCreators] = React.useState<string[]>([]);
const isTutorialMode = (() => { try { return localStorage.getItem('refinityMode') === 'tutorial'; } catch { return false; } })();
const currentUserLower = String(username || '').toLowerCase();
const isAdminUser = (() => {
try { return String(JSON.parse(localStorage.getItem('user') || '{}')?.role || '').toLowerCase() === 'admin'; } catch { return false; }
})();
const adminCanEditAnnotations = !(isTutorialMode && isAdminUser && adminAnnoView !== ADMIN_ANNO_VIEW_EDIT);
// Default admin view to clean on each version to avoid accidental carry-over.
React.useEffect(() => {
if (isTutorialMode && isAdminUser) setAdminAnnoView(ADMIN_ANNO_VIEW_CLEAN);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [versionId]);
const ANNO_STORE_KEY = 'refinity_annotations_v1';
type Ann = Annotation;
const loadAnnotations = React.useCallback((): Ann[] => {
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: Ann[]) => { try { localStorage.setItem(ANNO_STORE_KEY, JSON.stringify(list)); } catch {} }, []);
const [annotations, setAnnotations] = React.useState<Ann[]>(() => loadAnnotations());
React.useEffect(() => { saveAnnotations(annotations); }, [annotations, saveAnnotations]);
const versionAnnotations = React.useMemo(() => {
const base = annotations.filter(a => a.versionId === versionId);
if (isTutorialMode) {
if (isAdminUser) {
if (adminAnnoView === ADMIN_ANNO_VIEW_CLEAN) return [];
if (adminAnnoView === ADMIN_ANNO_VIEW_EDIT) return base.filter(a => String(a.createdBy || '').toLowerCase() === currentUserLower);
if (adminAnnoView === ADMIN_ANNO_VIEW_LEGACY) return base.filter(a => !a.createdBy);
return base.filter(a => String(a.createdBy || '').toLowerCase() === String(adminAnnoView || '').toLowerCase());
}
// Student (or non-admin): be defensive and only show own (or legacy) highlights.
return base.filter(a => !a.createdBy || String(a.createdBy || '').toLowerCase() === currentUserLower);
}
return base;
}, [annotations, versionId, isTutorialMode, isAdminUser, adminAnnoView, currentUserLower]);
const addAnnotation = (a: Ann) => setAnnotations(prev => [...prev, a]);
const updateAnnotation = (a: Ann) => setAnnotations(prev => prev.map(x => x.id === a.id ? a : x));
const deleteAnnotationById = (id: string) => setAnnotations(prev => prev.filter(a => a.id !== id));
const CATEGORY_CLASS: Record<AnnotationCategory, string> = {
distortion: 'bg-rose-100 ring-rose-200',
omission: 'bg-amber-100 ring-amber-200',
register: 'bg-indigo-100 ring-indigo-200',
unidiomatic: 'bg-purple-100 ring-purple-200',
grammar: 'bg-blue-100 ring-blue-200',
spelling: 'bg-green-100 ring-green-200',
punctuation: 'bg-teal-100 ring-teal-200',
addition: 'bg-pink-100 ring-pink-200',
other: 'bg-gray-100 ring-gray-200',
};
const [modalOpen, setModalOpen] = React.useState(false);
const [editingAnn, setEditingAnn] = React.useState<Ann | null>(null);
const [modalCategory, setModalCategory] = React.useState<AnnotationCategory>('distortion');
const [modalComment, setModalComment] = React.useState('');
const [modalCorrection, setModalCorrection] = React.useState('');
const [modalIsDeletion, setModalIsDeletion] = React.useState(false);
const [modalSelectedText, setModalSelectedText] = React.useState('');
const [modalSourceSnippet, setModalSourceSnippet] = React.useState('');
const [showSourcePane, setShowSourcePane] = React.useState(false);
const [popover, setPopover] = React.useState<{ left: number; top: number; start: number; end: number } | null>(null);
function escapeHtml(s: string): string {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Load persisted annotations for this version from backend
React.useEffect(() => {
(async () => {
if (!versionId) return;
try {
const resp = await fetch(getApiBase(`/annotations?versionId=${encodeURIComponent(versionId)}`));
const rows = await resp.json().catch(()=>[]);
if (Array.isArray(rows)) {
// In tutorial mode, filter to only show annotations created by current user (unless admin)
const isTutorial = localStorage.getItem('refinityMode') === 'tutorial';
const user = localStorage.getItem('user');
const currentUsername = user ? (JSON.parse(user)?.name || JSON.parse(user)?.email || '').toLowerCase() : '';
const isAdmin = user ? (JSON.parse(user)?.role || '').toLowerCase() === 'admin' : false;
let filteredRows = rows;
if (isTutorial && !isAdmin && currentUsername) {
// Only show annotations that either don't have createdBy (legacy) or were created by current user
filteredRows = rows.filter((r: any) => !r.createdBy || r.createdBy.toLowerCase() === currentUsername);
}
// For tutorial admin, capture the list of creators for the dropdown.
if (isTutorial && isAdmin) {
const creators = Array.from(new Set(
rows
.map((r: any) => (r?.createdBy ? String(r.createdBy).toLowerCase() : ''))
.filter(Boolean)
)).sort();
setAnnoCreators(creators);
} else {
setAnnoCreators([]);
}
setAnnotations(prev => {
const others = prev.filter(a => a.versionId !== versionId);
const loaded = filteredRows.map((r:any)=>{
const existing = prev.find(a => a.id === r._id);
return {
id: r._id,
versionId: r.versionId,
start: r.start,
end: r.end,
category: r.category,
comment: r.comment,
correction: (r.correction !== undefined ? r.correction : existing?.correction),
createdBy: (r.createdBy !== undefined ? r.createdBy : existing?.createdBy),
createdAt: r.createdAt,
updatedAt: r.updatedAt,
} as Ann;
});
return [...others, ...loaded];
});
}
} catch {}
})();
}, [versionId, getApiBase]);
// Persistence helpers
const persistCreate = React.useCallback(async (a: Ann): Promise<Ann> => {
try {
const body = { versionId: a.versionId, start: a.start, end: a.end, category: a.category, comment: a.comment, correction: a.correction };
const headers: any = { 'Content-Type': 'application/json' };
// Pass username in header for tutorial mode filtering
if (username && username !== 'Anonymous') {
headers['x-user-name'] = username;
}
const resp = await fetch(getApiBase('/annotations'), { method: 'POST', headers, body: JSON.stringify(body) });
const row = await resp.json().catch(()=>({}));
if (resp.ok && row && row._id) return { ...a, id: row._id };
} catch {}
return a;
}, [getApiBase, username]);
const persistUpdate = React.useCallback(async (a: Ann) => {
try {
const body = { start: a.start, end: a.end, category: a.category, comment: a.comment, correction: a.correction };
await fetch(getApiBase(`/annotations/${encodeURIComponent(a.id)}`), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
} catch {}
}, [getApiBase]);
const persistDelete = React.useCallback(async (id: string) => {
try {
await fetch(getApiBase(`/annotations/${encodeURIComponent(id)}`), { method: 'DELETE' });
} catch {}
}, [getApiBase]);
function renderAnnotatedHtml(textValue: string): string {
// Always wrap text in spans with data-start/data-end for selection offset calculation
// even when annotations are disabled or empty
if (!showAnnotations || !versionAnnotations.length) {
return `<span data-start="0" data-end="${textValue.length}">${escapeHtml(textValue)}</span>`;
}
const list = versionAnnotations.slice().sort((a,b)=>a.start-b.start);
let html = ''; let pos = 0;
for (const a of list) {
const start = Math.max(0, Math.min(a.start, textValue.length));
const end = Math.max(start, Math.min(a.end, textValue.length));
if (start > pos) {
const plain = textValue.slice(pos, start);
if (plain) {
html += `<span data-start="${pos}" data-end="${start}">${escapeHtml(plain)}</span>`;
}
}
const cls = CATEGORY_CLASS[a.category] || 'bg-gray-100';
const spanText = escapeHtml(textValue.slice(start, end)) || '&nbsp;';
html += `<span data-anno-id="${a.id}" data-start="${start}" data-end="${end}" class="inline rounded-[4px] ${cls}" title="${a.category}${a.comment ? ': '+escapeHtml(a.comment) : ''}">${spanText}</span>`;
pos = end;
}
if (pos < textValue.length) {
const tail = textValue.slice(pos);
if (tail) {
html += `<span data-start="${pos}" data-end="${textValue.length}">${escapeHtml(tail)}</span>`;
}
}
return html;
}
// Helper: find paragraph snippet in source text that roughly corresponds to the
// paragraph containing the given offset in the translation text.
function computeSourceSnippetForOffset(sourceText: string, translationText: string, offset: number): string {
if (!sourceText || !translationText) return '';
// Find the paragraph boundaries in a text for a given position
const findParagraph = (text: string, pos: number): { start: number; end: number } => {
const len = text.length;
let paraStart = 0;
let paraEnd = len;
// Look backwards from position to find paragraph start
// Priority: double newline > single newline (if followed by capital/number/CJK)
for (let i = Math.min(pos, len - 1); i >= 0; i--) {
// Check for double newline (strong paragraph break)
if (i > 0 && text[i - 1] === '\n' && text[i] === '\n') {
paraStart = i + 1;
break;
}
// Check for single newline followed by what looks like a new sentence/paragraph
if (i > 0 && text[i - 1] === '\n') {
// Skip whitespace after newline
let j = i;
while (j < len && (text[j] === ' ' || text[j] === '\t')) j++;
if (j < len) {
const char = text[j];
// If it starts with capital, number, or CJK character, likely a new paragraph
if ((char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || (char >= '\u4e00' && char <= '\u9fff')) {
paraStart = i;
break;
}
}
}
if (i === 0) {
paraStart = 0;
break;
}
}
// Look forwards from position to find paragraph end
for (let i = Math.max(pos, paraStart); i < len; i++) {
// Check for double newline (strong paragraph break)
if (i < len - 1 && text[i] === '\n' && text[i + 1] === '\n') {
paraEnd = i;
break;
}
// Check for single newline that might be a paragraph end
if (i < len - 1 && text[i] === '\n' && text[i + 1] !== '\n') {
// Skip whitespace after newline
let j = i + 1;
while (j < len && (text[j] === ' ' || text[j] === '\t')) j++;
if (j < len) {
const nextChar = text[j];
// If next line starts with capital/number/CJK, this might be a paragraph end
if ((nextChar >= 'A' && nextChar <= 'Z') || (nextChar >= '0' && nextChar <= '9') || (nextChar >= '\u4e00' && nextChar <= '\u9fff')) {
// Make sure there's actual content before this newline
let hasContent = false;
for (let k = paraStart; k < i; k++) {
if (text[k] !== ' ' && text[k] !== '\n' && text[k] !== '\t' && text[k] !== '\r') {
hasContent = true;
break;
}
}
if (hasContent) {
paraEnd = i;
break;
}
}
}
}
if (i === len - 1) {
paraEnd = len;
break;
}
}
return { start: paraStart, end: paraEnd };
};
// Get the paragraph range in translation text that contains the offset
const trPara = findParagraph(translationText, offset);
// Find corresponding paragraph in source text
// Use position ratio to estimate which paragraph in source corresponds
const trRatio = trPara.start / Math.max(translationText.length, 1);
const estimatedSrcPos = Math.floor(sourceText.length * trRatio);
const srcPara = findParagraph(sourceText, estimatedSrcPos);
// Extract the source paragraph
const snippet = sourceText.slice(srcPara.start, srcPara.end).trim();
// If snippet is too long (more than 3x the translation paragraph), try to be more selective
if (snippet.length > (trPara.end - trPara.start) * 3 && snippet.length > 500) {
// Try to find a more focused section - look for sentences around the estimated position
const sentences = snippet.split(/[.!?。!?]\s+/);
if (sentences.length > 1) {
const midIdx = Math.floor(sentences.length / 2);
const startIdx = Math.max(0, midIdx - 1);
const endIdx = Math.min(sentences.length, midIdx + 2);
return sentences.slice(startIdx, endIdx).join('. ').trim();
}
}
return snippet || sourceText.slice(0, Math.min(500, sourceText.length)); // Fallback to first 500 chars if empty
}
// Adjust annotations when text changes so highlights track or are removed
const adjustAnnotationsForEdit = React.useCallback((oldText: string, newText: string) => {
if (oldText === newText) return;
const oldLen = oldText.length, newLen = newText.length;
let i = 0;
while (i < oldLen && i < newLen && oldText[i] === newText[i]) i++;
let a = oldLen - 1, b = newLen - 1;
while (a >= i && b >= i && oldText[a] === newText[b]) { a--; b--; }
const oldEditStart = i;
const oldEditEnd = a + 1; // [oldEditStart, oldEditEnd) replaced
const delta = (b + 1) - (a + 1); // change in length
setAnnotations(prev => {
const others = prev.filter(p => p.versionId !== versionId);
const mine = prev.filter(p => p.versionId === versionId);
const adjusted: Ann[] = [];
mine.forEach(ann => {
if (ann.end <= oldEditStart) {
// entirely before edit region
adjusted.push(ann);
} else if (ann.start >= oldEditEnd) {
// entirely after edit region → shift by delta
adjusted.push({ ...ann, start: ann.start + delta, end: ann.end + delta });
} else {
// overlaps the edited region → remove (treat highlight as deleted with text)
// do nothing (drop this annotation)
}
});
return [...others, ...adjusted];
});
}, [versionId, setAnnotations]);
// Map current selection within annotatedRef to plain-text offsets in `text`,
// using data-start/data-end spans emitted by renderAnnotatedHtml for exact mapping.
function getOffsetsFromSelection(): { start: number; end: number; rect: DOMRect | null } | null {
const container = annotatedRef.current;
if (!container) return null;
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) return null;
const resolveOffset = (node: Node, offset: number): number | null => {
// Find the containing span with data-start attribute
let el: HTMLElement | null = (node.nodeType === Node.ELEMENT_NODE
? node as HTMLElement
: node.parentElement as HTMLElement | null);
while (el && el !== container && !el.hasAttribute('data-start')) {
el = el.parentElement;
}
if (!el || !el.hasAttribute('data-start')) {
// Fallback: try to find any span with data-start in the container
const allSpans = container.querySelectorAll('[data-start]');
if (allSpans.length === 0) return null;
// Use first span as fallback
el = allSpans[0] as HTMLElement;
}
const baseStart = Number(el.getAttribute('data-start') || '0') || 0;
// Create a range from the start of this span to the target node/offset
const r = document.createRange();
try {
r.selectNodeContents(el);
r.setEnd(node, offset);
} catch (e) {
// If setting end fails, return baseStart as fallback
return baseStart;
}
const localLen = r.toString().length;
return baseStart + localLen;
};
const start = resolveOffset(range.startContainer, range.startOffset);
const end = resolveOffset(range.endContainer, range.endOffset);
if (start == null || end == null) {
// Fallback: walk text nodes to calculate offset
try {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let charCount = 0;
let startOffset = -1;
let endOffset = -1;
while (walker.nextNode()) {
const node = walker.currentNode as Text;
const nodeText = node.nodeValue || '';
if (node === range.startContainer) {
startOffset = charCount + range.startOffset;
}
if (node === range.endContainer) {
endOffset = charCount + range.endOffset;
break;
}
charCount += nodeText.length;
}
if (startOffset >= 0 && endOffset >= 0 && endOffset > startOffset) {
const rect = range.getBoundingClientRect();
return { start: startOffset, end: endOffset, rect };
}
} catch (e) {
console.debug('[getOffsetsFromSelection] fallback failed:', e);
}
return null;
}
const rect = range.getBoundingClientRect();
return { start, end, rect };
}
const updateSelectionPopover = React.useCallback(() => {
const wrap = wrapperRef.current;
const container = annotatedRef.current;
if (!wrap || !container) {
setPopover(null);
return;
}
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
setPopover(null);
return;
}
const res = getOffsetsFromSelection();
if (!res) {
setPopover(null);
return;
}
const { start, end, rect } = res;
if (!rect) {
setPopover(null);
return;
}
if (end <= start) {
setPopover(null);
return;
}
const wrect = wrap.getBoundingClientRect();
const btnW = 120, btnH = 30, padding = 6;
// Center over the selection; clamp within container
let left = rect.left - wrect.left + (rect.width - btnW) / 2;
left = Math.max(padding, Math.min(left, wrect.width - btnW - padding));
// Prefer above the selection; if not enough space, place below
let top = rect.top - wrect.top - btnH - padding;
if (top < padding) {
top = rect.bottom - wrect.top + padding;
}
setPopover({ left, top, start, end });
}, []);
React.useEffect(() => {
if (sourceRef.current) {
const height = sourceRef.current.offsetHeight;
setTextareaHeight(`${height}px`);
}
}, [source]);
// Re-measure when fullscreen toggles (layout changes)
React.useEffect(() => {
const measure = () => {
if (sourceRef.current) {
const h = sourceRef.current.offsetHeight;
setTextareaHeight(`${h}px`);
}
};
// double rAF to wait for layout stabilization
requestAnimationFrame(() => requestAnimationFrame(measure));
}, [isFullscreen]);
// Re-measure on window resize
React.useEffect(() => {
const onResize = () => {
if (sourceRef.current) {
const h = sourceRef.current.offsetHeight;
setTextareaHeight(`${h}px`);
}
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// Keep overlay aligned and font-synced with textarea across renders/toggles
React.useLayoutEffect(() => {
const ta = taRef.current;
const ov = overlayRef.current;
if (!ta || !ov) return;
// Scroll alignment
ov.style.transform = `translateY(-${ta.scrollTop}px)`;
// Font/layout alignment
try {
const cs = window.getComputedStyle(ta);
ov.style.fontFamily = cs.fontFamily;
ov.style.fontSize = cs.fontSize;
ov.style.lineHeight = cs.lineHeight;
ov.style.letterSpacing = cs.letterSpacing;
ov.style.whiteSpace = cs.whiteSpace;
} catch {}
}, [showAnnotations, text, textareaHeight, isFullscreen]);
// Build corrected text from original text and annotation corrections
const buildCorrectedText = React.useCallback((): string => {
const base = initialTranslation || '';
if (!versionAnnotations.length) return base;
const anns = versionAnnotations
// Include deletions: we represent them as empty-string corrections.
// (Previously we filtered out empty corrections, which caused deletions to be ignored on save.)
.filter(a => typeof a.correction === 'string')
.slice()
.sort((a, b) => a.start - b.start || a.end - b.end);
if (!anns.length) return base;
let result = '';
let pos = 0;
for (const a of anns) {
const start = Math.max(0, Math.min(a.start, base.length));
const end = Math.max(start, Math.min(a.end, base.length));
if (start < pos) {
// overlapping or out-of-order; skip this annotation to avoid corrupting text
continue;
}
if (pos < start) {
result += base.slice(pos, start);
}
result += a.correction as string;
pos = end;
}
if (pos < base.length) {
result += base.slice(pos);
}
return result;
}, [initialTranslation, versionAnnotations]);
// Keep overlay aligned with textarea scroll position across renders/toggles
React.useLayoutEffect(() => {
const ta = taRef.current;
const ov = overlayRef.current;
if (!ta || !ov) return;
ov.style.transform = `translateY(-${ta.scrollTop}px)`;
}, [showAnnotations, text, textareaHeight, isFullscreen]);
const save = async ()=>{
setSaving(true);
try {
if (onSaveEdit) {
// Editing mode: save the text as-is
await onSaveEdit(text);
} else {
// Revision mode: apply all corrections from annotations before saving
const correctedText = buildCorrectedText();
onSave(correctedText);
}
} finally {
setSaving(false);
}
};
const compareNow = async ()=>{
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
// Use corrected text for comparison if in revision mode (not editing mode)
const currentText = onSaveEdit ? text : buildCorrectedText();
const resp = await fetch(`${base}/api/refinity/diff`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prev: initialTranslation || '', current: currentText || '' }) });
const data = await resp.json().catch(()=>({}));
if (!resp.ok) throw new Error(data?.error || 'Diff failed');
// Ensure layout preserved if backend HTML lacks <br/>
const raw = String(data?.html || '');
const ensured = raw.includes('<br') ? raw : raw.replace(/\n/g, '<br/>');
setDiffHtml(ensured);
setShowDiff(true);
} catch {}
};
const downloadWithTrackChanges = async ()=>{
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const taskNameSafe = (taskTitle || 'Task').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_');
const userNameSafe = toSafeName(username || 'User');
const vNum = `v${nextVersionNumber}`;
const filename = `${taskNameSafe}_${vNum}_Clean_${yyyymmdd_hhmm()}_${userNameSafe}.docx`;
// Use corrected text if in revision mode
const currentText = onSaveEdit ? text : buildCorrectedText();
const resp = await fetch(`${base}/api/refinity/track-changes-ooxml`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prev: initialTranslation || '', current: currentText || '', filename, includeComments: false }) });
if (!resp.ok) throw new Error('Export failed');
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch {
alert('Export failed');
}
};
return (
<div>
<div className="flex items-start gap-6">
<div className="w-1/2">
<div className="mb-2 text-gray-700 text-sm flex items-center justify-between">
<span>Source</span>
<div className="flex items-center gap-2">
{!isFullscreen && (
<button
className="inline-flex items-center px-2 py-1 text-xs rounded-xl opacity-0 pointer-events-none"
>
Full Screen
</button>
)}
</div>
</div>
<div className="relative rounded-lg overflow-hidden">
<div className="absolute inset-0 rounded-lg" style={{ background: 'radial-gradient(120%_120%_at_0%_0%, #dbeafe 0%, #ffffff 50%, #c7d2fe 100%)' }} />
<div ref={sourceRef} className="relative rounded-lg bg-white/60 backdrop-blur-md ring-1 ring-inset ring-indigo-300 shadow p-4 min-h-[420px] whitespace-pre-wrap text-gray-900">
{source}
</div>
</div>
</div>
<div className="w-1/2">
<div className="mb-2 text-gray-700 text-sm flex items-center justify-between">
<span>Translation</span>
<div className="flex items-center gap-2">
{isTutorialMode && isAdminUser && !onSaveEdit && (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-700">Highlights</span>
<select
value={adminAnnoView}
onChange={(e)=>setAdminAnnoView(e.target.value)}
className="h-8 px-2 text-sm rounded-lg border border-gray-300 bg-white"
title="Select which user's highlights to display (admin-only)"
>
<option value={ADMIN_ANNO_VIEW_CLEAN}>Clean (none)</option>
<option value={ADMIN_ANNO_VIEW_EDIT}>Edit (my highlights)</option>
{annoCreators.length > 0 && <option disabled>──────────</option>}
{annoCreators.map(u => (
<option key={u} value={u}>{u}</option>
))}
<option value={ADMIN_ANNO_VIEW_LEGACY}>Legacy (unknown)</option>
</select>
</div>
)}
{!isFullscreen && (
<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">
Full Screen
</button>
)}
</div>
</div>
<div ref={wrapperRef} className="relative overflow-hidden">
{/* Single-surface annotated view (exact alignment) */}
{onSaveEdit ? (
// Editing mode: use textarea for direct editing
<textarea
value={text}
onChange={(e) => {
setText(e.target.value);
setDirty(true);
}}
className="relative z-10 w-full px-4 py-3 border border-ui-border rounded-lg bg-white text-base leading-relaxed font-sans tracking-normal whitespace-pre-wrap overflow-y-auto resize-none"
style={{
minHeight: textareaHeight,
height: textareaHeight,
maxHeight: textareaHeight,
}}
/>
) : (
// Revision mode: use annotated div
<div
ref={annotatedRef}
className="relative z-10 w-full px-4 py-3 border border-ui-border rounded-lg bg-white text-base leading-relaxed font-sans tracking-normal whitespace-pre-wrap overflow-y-auto select-text"
style={{
minHeight: textareaHeight,
height: textareaHeight,
maxHeight: textareaHeight,
userSelect: 'text',
WebkitUserSelect: 'text',
}}
onMouseDown={(e) => {
// Don't clear popover on mousedown - let mouseup handle it
// This allows selection to work properly
}}
onMouseUp={(e) => {
// Use setTimeout to ensure selection is stable
setTimeout(() => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
setPopover(null);
return;
}
const res = getOffsetsFromSelection();
if (!res) {
setPopover(null);
return;
}
const { start, end } = res;
if (end - start <= 0) {
// collapsed caret: if inside an existing annotation, open edit modal
const hit = versionAnnotations.find(a => start >= a.start && start <= a.end);
if (hit) {
setEditingAnn(hit);
setModalCategory(hit.category);
setModalComment(hit.comment || '');
const selected = text.slice(hit.start, hit.end);
setModalSelectedText(selected);
setModalCorrection(hit.correction ?? selected);
// If correction is empty-string, treat it as a deletion annotation.
setModalIsDeletion(typeof hit.correction === 'string' && hit.correction.trim() === '');
setModalSourceSnippet(
computeSourceSnippetForOffset(source, text, hit.start)
);
setShowSourcePane(false);
setModalOpen(true);
return;
}
setPopover(null);
return;
}
if (!adminCanEditAnnotations) {
// View-only mode: allow inspecting existing highlights (handled above),
// but do not allow creating new highlights via selection.
setPopover(null);
return;
}
updateSelectionPopover();
}, 10);
}}
onKeyUp={()=>{
setTimeout(() => {
updateSelectionPopover();
}, 10);
}}
dangerouslySetInnerHTML={{ __html: renderAnnotatedHtml(text) }}
/>
)}
{/* + Comment popover */}
{popover && (
<div className="absolute z-20" style={{ left: popover.left, top: popover.top }}>
<button
type="button"
onClick={()=>{
if (!adminCanEditAnnotations) { showToast('Switch “Highlights” to Edit (my highlights) to add.'); return; }
setEditingAnn(null);
setModalCategory('distortion');
setModalComment('');
if (popover) {
const sel = text.slice(popover.start, popover.end);
setModalSelectedText(sel);
setModalCorrection(sel);
setModalIsDeletion(false);
setModalSourceSnippet(
computeSourceSnippetForOffset(source, text, popover.start)
);
} else {
setModalSelectedText('');
setModalCorrection('');
setModalIsDeletion(false);
setModalSourceSnippet('');
}
setShowSourcePane(false);
setModalOpen(true);
}}
disabled={!adminCanEditAnnotations}
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"
>
<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%)]" />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
<span className="relative z-10">+ Comment</span>
</button>
</div>
)}
</div>
<div className="mt-4 flex gap-3 relative items-center">
<button
onClick={() => { setDirty(false); save(); showToast('Saved'); }}
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 hover:bg-indigo-700 disabled:bg-gray-400 active:translate-y-0.5 transition-all duration-200"
>
<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%)]" />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
{saving? 'Saving…':'Save'}
</button>
<div className="relative inline-block align-top">
<button
ref={revBtnRef}
onClick={(e)=>{
e.preventDefault(); e.stopPropagation();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
const pad = 8, menuW = 224, menuH = 100;
let left = Math.min(Math.max(r.left, pad), window.innerWidth - menuW - pad);
let top = r.bottom + pad;
if (top + menuH > window.innerHeight - pad) top = r.top - menuH - pad;
setRevMenuPos({ left, top });
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>
{revDownloadOpen && revMenuPos && createPortal(
<div style={{ position: 'fixed', left: revMenuPos.left, top: revMenuPos.top, zIndex: 10000, maxHeight: '240px', overflowY: 'auto' }} className="w-56 rounded-md border border-gray-200 bg-white shadow-lg text-left">
<button onClick={async()=>{ setRevDownloadOpen(false); try { const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const filename=`${toSafeName(taskTitle||'Task')}_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`; const currentText = onSaveEdit ? text : buildCorrectedText(); const body={ current: currentText||'', filename }; let resp=await fetch(`${base}/api/refinity/export-plain`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body) }); if(!resp.ok) throw new Error('Export failed'); const blob=await resp.blob(); await saveBlobToDisk(blob, filename);} catch { const currentText = onSaveEdit ? text : buildCorrectedText(); await saveTextFallback(currentText||'', `${toSafeName(taskTitle||'Task')}_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.txt`);} }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Without Annotations</button>
<button onClick={async()=>{ setRevDownloadOpen(false); try { const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const filename=`${toSafeName(taskTitle||'Task')}_v${nextVersionNumber}_Annotated_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`; const currentText = onSaveEdit ? text : buildCorrectedText(); const list=versionAnnotations.map(a=>({ start:a.start, end:a.end, category:a.category, comment:a.comment })); const body={ current: currentText||'', filename, annotations: list }; let resp=await fetch(`${base}/api/refinity/export-plain-with-annotations`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body) }); if(!resp.ok){ resp=await fetch(`${base}/api/refinity/export-plain`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ current: currentText||'', filename }) }); } if(!resp.ok) throw new Error('Export failed'); const blob=await resp.blob(); await saveBlobToDisk(blob, filename);} catch { const currentText = onSaveEdit ? text : buildCorrectedText(); await saveTextFallback(currentText||'', `${toSafeName(taskTitle||'Task')}_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.txt`);} }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">With Annotations</button>
</div>, document.body
)}
</div>
{/* Comments button removed per request */}
<button onClick={()=>{ if (!dirty || window.confirm('Discard unsaved changes?')) { setDirty(false); 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>
</div>
{/* Inline comments drawer removed */}
{showDiff && (
<div className="mt-6">
<div className="relative rounded-xl">
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-indigo-200/45 via-indigo-100/40 to-indigo-300/45" />
<div className="relative rounded-xl bg-white/10 backdrop-blur-md ring-1 ring-inset ring-white/30 shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)] p-4">
<div className="pointer-events-none absolute inset-0 rounded-xl opacity-50 [background:linear-gradient(to_bottom,rgba(255,255,255,0.3),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.28),rgba(255,255,255,0)_28%)]" />
<div className="pointer-events-none absolute inset-0 rounded-xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-45" />
<div className="relative text-gray-900 prose prose-sm max-w-none whitespace-pre-wrap" dangerouslySetInnerHTML={{ __html: diffHtml }} />
</div>
</div>
<div className="mt-4 flex justify-end">
<div className="relative inline-block">
<button
ref={diffBtnRef}
onClick={(e)=>{
e.preventDefault(); e.stopPropagation();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
const pad = 8, menuW = 200;
let left = Math.min(Math.max(r.left, pad), window.innerWidth - menuW - pad);
let top = r.bottom + pad;
if (top + 150 > window.innerHeight - pad) top = r.top - 150 - pad;
setDiffMenuPos({ left, top });
setDiffDownloadOpen(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>
{diffDownloadOpen && diffMenuPos && createPortal(
<div style={{ position: 'fixed', left: diffMenuPos.left, top: diffMenuPos.top, zIndex: 10000 }} className="w-52 rounded-md border border-gray-200 bg-white shadow-lg text-left">
<button onClick={async()=>{
setDiffDownloadOpen(false);
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const correctedText = buildCorrectedText();
const filename = `${toSafeName(taskTitle||'Task')}_Compare_Inline_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`;
const body = { prev: initialTranslation||'', current: correctedText||'', filename, authorName: username||'User' };
let resp = await fetch(`${base}/api/refinity/track-changes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!resp.ok) {
resp = await fetch(`${base}/api/refinity/export-plain`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current: correctedText||'', filename }) });
}
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">Inline Changes</button>
<button onClick={async()=>{
setDiffDownloadOpen(false);
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const correctedText = buildCorrectedText();
const filename = `${toSafeName(taskTitle||'Task')}_Compare_Tracked_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`;
const body = { prev: initialTranslation||'', current: correctedText||'', filename, authorName: username||'User', includeComments: false };
let resp = await fetch(`${base}/api/refinity/track-changes-ooxml`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!resp.ok) {
resp = await fetch(`${base}/api/refinity/export-plain`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current: correctedText||'', filename }) });
}
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">Tracked Changes</button>
<button onClick={async()=>{
setDiffDownloadOpen(false);
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/,'');
const correctedText = buildCorrectedText();
const filename = `${toSafeName(taskTitle||'Task')}_Compare_SidebarComments_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`;
const body: any = {
prev: initialTranslation || '',
current: correctedText || '',
filename,
authorName: username || 'User',
annotationVersionId: versionId,
};
let resp = await fetch(getApiBase('/compare-comments-with-corrections'), {
method: 'POST',
headers: (() => {
const user = JSON.parse(localStorage.getItem('user') || '{}');
const headers: any = { 'Content-Type': 'application/json' };
if (user.name) headers['x-user-name'] = user.name;
if (user.email) headers['x-user-email'] = user.email;
if (user.role) {
headers['x-user-role'] = user.role;
headers['user-role'] = user.role;
}
return headers;
})(),
body: JSON.stringify(body),
});
if (!resp.ok) {
resp = await fetch(getApiBase('/export-plain'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current: initialTranslation || '', filename }),
});
}
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">Sidebar Comments</button>
</div>, document.body
)}
</div>
</div>
</div>
)}
</div>
</div>
{toastMsg && (
<div className="fixed bottom-5 right-5 z-[6000] px-3 py-2 rounded-lg bg-black/80 text-white text-sm shadow-lg">{toastMsg}</div>
)}
{/* Annotation modal */}
{modalOpen && (
<div className="fixed inset-0 z-[5000] flex items-center justify-center bg-black/40">
<motion.div
initial={false}
animate={{ maxWidth: showSourcePane && modalSourceSnippet ? 900 : 480 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="bg-white rounded-xl shadow-xl w-full p-6"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">{editingAnn ? 'Edit annotation' : 'New annotation'}</h3>
{modalSourceSnippet && (
<button
type="button"
onClick={() => setShowSourcePane(v => !v)}
className="text-xs text-indigo-700 hover:text-indigo-900 underline-offset-2 hover:underline"
>
{showSourcePane ? 'Hide source' : 'Show source'}
</button>
)}
</div>
<div className="space-y-4">
<div className="md:flex md:space-x-4 space-y-4 md:space-y-0 items-start">
<AnimatePresence initial={false}>
{showSourcePane && modalSourceSnippet && (
<motion.div
key="source-pane"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="md:w-2/5 w-full space-y-1"
>
<label className="block text-sm text-gray-700">
Source paragraph
</label>
<div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm text-gray-800 whitespace-pre-wrap overflow-auto max-h-64">
{modalSourceSnippet}
</div>
</motion.div>
)}
</AnimatePresence>
<div className="flex-1 md:w-3/5 space-y-4">
<div>
<label className="block text-sm text-gray-700 mb-1">Selected text</label>
<div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm text-gray-800 whitespace-pre-wrap max-h-32 overflow-auto">
{modalSelectedText || '(no selection)'}
</div>
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Error type <span className="text-red-500">*</span></label>
<select
value={modalCategory}
onChange={(e)=>setModalCategory(e.target.value as AnnotationCategory)}
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm"
>
<option value="distortion">Distortion</option>
<option value="omission">Unjustified omission</option>
<option value="register">Inappropriate register</option>
<option value="unidiomatic">Unidiomatic expression</option>
<option value="grammar">Error of grammar, syntax</option>
<option value="spelling">Error of spelling</option>
<option value="punctuation">Error of punctuation</option>
<option value="addition">Unjustified addition</option>
<option value="other">Other</option>
</select>
</div>
<div>
<div className="mb-3">
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={modalIsDeletion}
onChange={(e) => {
setModalIsDeletion(e.target.checked);
if (e.target.checked) {
setModalCorrection(''); // Clear correction when marking as deletion
}
}}
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<span>Delete this text</span>
</label>
</div>
{!modalIsDeletion && (
<>
<label className="block text-sm text-gray-700 mb-1">Correction <span className="text-red-500">*</span></label>
<textarea
value={modalCorrection}
onChange={(e)=>setModalCorrection(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm mb-3"
placeholder="Enter the corrected wording for this span…"
/>
</>
)}
{modalIsDeletion && (
<div className="mb-3 p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-700">This text will be deleted. The selected text will be removed from the final version.</p>
</div>
)}
<label className="block text-sm text-gray-700 mb-1">Comment (optional)</label>
<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 explanation or feedback…"
/>
</div>
</div>
</div>
</div>
<div className="mt-6 flex items-center justify-between">
{editingAnn && (
<button
onClick={async()=>{
if (!adminCanEditAnnotations) { showToast('Switch “Highlights” to Edit (my highlights) to modify.'); return; }
await persistDelete(editingAnn.id);
deleteAnnotationById(editingAnn.id);
setModalOpen(false); setPopover(null); setEditingAnn(null); setModalIsDeletion(false);
}}
disabled={!adminCanEditAnnotations}
className={`px-3 py-1.5 text-sm rounded-lg text-white ${adminCanEditAnnotations ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-400 cursor-not-allowed'}`}
>
Delete
</button>
)}
<div className="ml-auto flex gap-2">
<button onClick={()=>{ setModalOpen(false); setPopover(null); setEditingAnn(null); setModalIsDeletion(false); }} className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 text-gray-700 bg-white hover:bg-gray-50">Cancel</button>
<button
onClick={async ()=>{
if (!adminCanEditAnnotations) { showToast('Switch “Highlights” to Edit (my highlights) to save changes.'); return; }
// Allow empty correction only if deletion is checked
if (!modalIsDeletion && !modalCorrection.trim()) {
alert('Please enter a correction for this highlighted text, or check "Delete this text" to mark it for deletion.');
return;
}
if (editingAnn) {
const updated = {
...editingAnn,
category: modalCategory,
comment: modalComment,
correction: modalIsDeletion ? '' : modalCorrection,
updatedAt: Date.now()
};
updateAnnotation(updated);
await persistUpdate(updated);
} else if (popover) {
const local = {
id:`a_${Date.now()}_${Math.random().toString(36).slice(2,7)}`,
versionId,
start: popover.start,
end: popover.end,
category: modalCategory,
comment: modalComment,
correction: modalIsDeletion ? '' : modalCorrection,
createdAt: Date.now(),
updatedAt: Date.now()
};
const saved = await persistCreate(local);
addAnnotation(saved);
}
setModalOpen(false); setPopover(null); setEditingAnn(null); setModalIsDeletion(false);
}}
disabled={!adminCanEditAnnotations}
className={`px-3 py-1.5 text-sm rounded-lg text-white ${adminCanEditAnnotations ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-gray-400 cursor-not-allowed'}`}
>
Save
</button>
</div>
</div>
</motion.div>
</div>
)}
</div>
);
};
export default Refinity;
const PreviewPane: React.FC<{ version: Version | null; onBack: ()=>void; onEdit: ()=>void; compareInitially?: boolean }>=({ version, onBack, onEdit, compareInitially })=>{
const [diffHtml, setDiffHtml] = React.useState<string>('');
const [showDiff, setShowDiff] = React.useState<boolean>(!!compareInitially);
React.useEffect(()=>{
setShowDiff(!!compareInitially);
},[compareInitially]);
if (!version) return (
<div>
<div className="text-gray-700">No version selected.</div>
<div className="mt-3">
<button onClick={onBack} 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">Back</button>
</div>
</div>
);
const doCompare = async ()=>{
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
// Compare this version with its parent if available
const prevText = (version as any)?.parentContent || '';
const resp = await fetch(`${base}/api/refinity/diff`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prev: prevText, current: version.content || '' }) });
const data = await resp.json().catch(()=>({}));
if (!resp.ok) throw new Error(data?.error || 'Diff failed');
setDiffHtml(String(data?.html || ''));
setShowDiff(true);
} catch {}
};
return (
<div>
<div className="relative rounded-xl ring-1 ring-gray-200 shadow-sm overflow-hidden">
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-orange-50 via-amber-50 to-white" />
<div className="relative rounded-xl p-6">
{!showDiff && (
<div className="relative whitespace-pre-wrap text-gray-900 min-h-[220px]">{version.content}</div>
)}
{showDiff && (
<div className="relative prose prose-sm max-w-none text-gray-900" dangerouslySetInnerHTML={{ __html: diffHtml }} />
)}
</div>
</div>
<div className="mt-4 flex gap-3">
<button
onClick={onEdit}
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-orange-600/70 hover:bg-orange-700 active:translate-y-0.5 transition-all duration-200"
>
<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%)]" />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
<span className="relative z-10">Revise</span>
</button>
<button onClick={onBack} 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">Back</button>
{/* Compare removed in revision mode per request */}
</div>
</div>
);
};