TransHub / client /src /components /Refinity.tsx
linguabot's picture
Upload folder using huggingface_hub
817c16d verified
raw
history blame
64.4 kB
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 Task = {
id: string;
title: string;
sourceText: string;
};
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 = () => {
const [stage, setStage] = React.useState<Stage>('task');
const [tasks, setTasks] = React.useState<Task[]>([]);
const [selectedTaskId, setSelectedTaskId] = React.useState<string>('');
const [versions, setVersions] = React.useState<Version[]>([]);
const [currentVersionId, setCurrentVersionId] = React.useState<string | null>(null);
const [previewVersionId, setPreviewVersionId] = React.useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = React.useState<boolean>(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);
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 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
React.useEffect(() => {
(async () => {
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const resp = await fetch(`${base}/api/refinity/tasks`);
const data: any[] = await resp.json().catch(()=>[]);
const normalized: Task[] = Array.isArray(data) ? data.map(d => ({ id: d._id, title: d.title, sourceText: d.sourceText })) : [];
setTasks(normalized);
if (normalized.length && !selectedTaskId) setSelectedTaskId(normalized[0].id);
} catch {}
})();
}, []);
// Load versions when task changes
React.useEffect(() => {
(async () => {
if (!task?.id) { setVersions([]); return; }
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const resp = await fetch(`${base}/api/refinity/tasks/${encodeURIComponent(task.id)}/versions`);
const data: any[] = await resp.json().catch(()=>[]);
const normalized: Version[] = Array.isArray(data) ? data.map(d => ({ id: d._id, taskId: d.taskId, originalAuthor: d.originalAuthor, revisedBy: d.revisedBy, versionNumber: d.versionNumber, content: d.content, parentVersionId: d.parentVersionId })) : [];
setVersions(normalized);
setCurrentVersionId(normalized.length ? normalized[normalized.length-1].id : null);
} catch { setVersions([]); }
})();
}, [task?.id]);
const deleteVersion = React.useCallback(async (versionId: string) => {
try {
const ok = window.confirm('Delete this version? This cannot be undone.');
if (!ok) return;
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const resp = await fetch(`${base}/api/refinity/versions/${encodeURIComponent(versionId)}`, {
method: 'DELETE',
headers: { 'x-user-role': 'admin' }
});
if (!resp.ok) throw new Error('Delete failed');
setVersions(prev => prev.filter(v => v.id !== versionId));
} catch {}
}, []);
const [flowIndex, setFlowIndex] = React.useState<number>(0);
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]);
// Keyboard navigation for cover flow
React.useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
setFlowIndex(i => Math.max(i - 1, 0));
} else if (e.key === 'ArrowRight') {
setFlowIndex(i => Math.min(i + 1, Math.max(taskVersions.length - 1, 0)));
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [taskVersions.length]);
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);
const resp = await fetch(`${base}/api/refinity/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
const base2 = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const resp2 = await fetch(`${base2}/api/refinity/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]);
setCurrentVersionId(newVersion.id);
setCurrentVersionId(null);
setStage('flow');
} finally {
setUploading(false);
}
};
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 resp = await fetch(`${base}/api/refinity/parse`, { 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 base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const body = { title, sourceText: src, createdBy: username };
const resp = await fetch(`${base}/api/refinity/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, 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 };
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
const respTasks = await fetch(`${base}/api/refinity/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 })) : [];
if (!taskList.length) { setTaskStageNote('No tasks available yet.'); return; }
const randTask = taskList[Math.floor(Math.random() * taskList.length)];
// Load versions for that task
const respVers = await fetch(`${base}/api/refinity/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) return;
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const resp = await fetch(`${base}/api/refinity/tasks/${encodeURIComponent(task?.id || '')}/versions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ originalAuthor: username, revisedBy: undefined, content: text }) });
const saved = await resp.json().catch(()=>({}));
if (!resp.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]);
setCurrentVersionId(newVersion.id);
setPastedTranslation('');
setCurrentVersionId(null);
setStage('flow');
} catch {}
};
const selectManual = (id: string) => {
setCurrentVersionId(id);
setStage('editor');
};
const handleSaveRevision = async (newContent: string) => {
const parent = taskVersions.find(v => v.id === currentVersionId);
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const resp = await fetch(`${base}/api/refinity/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) 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]);
setCurrentVersionId(v.id);
setCurrentVersionId(null);
setStage('flow');
} catch {
// no-op; keep user in editor if needed
}
};
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>
)}
{/* 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" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-end">
<div className="md:col-span-1">
<label className="block text-sm text-gray-700 mb-1">Task</label>
<div className="relative inline-block w-full md:w-64">
<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={()=>{ setSelectedTaskId(t.id); setTaskMenuOpen(false); }} 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>
<div className="md:col-span-2">
<label className="block text-sm text-gray-700 mb-1">Upload .doc or .docx translation</label>
<div className="flex items-center gap-3">
<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="text-sm text-gray-600">Uploading…</span>}
</div>
<div className="mt-4">
<label className="block text-sm text-gray-700 mb-1">Or paste translation</label>
<textarea value={pastedTranslation} onChange={(e)=>setPastedTranslation(e.target.value)} rows={3} className="relative z-10 w-full px-4 py-3 border border-ui-border rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white" 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>
</div>
<div className="mt-8 flex gap-3 items-center flex-wrap">
<button onClick={assignRandom} 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">
<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">Random Mode</span>
</button>
{taskVersions.length > 0 && (
<button onClick={()=>setStage('flow')} 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">
<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">Manual Selection</span>
</button>
)}
<button onClick={()=>setShowAddTask(v=>!v)} 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">
<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">New Task</span>
</button>
{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>
)}
{/* 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={()=>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={24}
preventClicks={false}
preventClicksPropagation={false}
noSwiping={true}
noSwipingClass={'swiper-no-swiping'}
breakpoints={{
640: { spaceBetween: 28 },
1024: { spaceBetween: 32 },
1440: { spaceBetween: 40 }
}}
observer
observeParents
grabCursor
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 limit = 160;
const snippet = (v.content || '').slice(0, limit) + ((v.content || '').length > limit ? '…' : '');
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 className={`text-gray-900 whitespace-pre-wrap break-words leading-relaxed flex-1 overflow-hidden pr-1`}>{snippet}</div>
{isCenter && (
<div className="action-row absolute left-6 right-6 bottom-6 swiper-no-swiping" data-version-number={v.versionNumber} 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"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log('Revise clicked for version:', v.versionNumber, 'id:', v.id);
selectManual(v.id);
}}
className="inline-flex items-center justify-center gap-2 text-white text-sm font-medium rounded-2xl ring-1 ring-inset ring-white/50 active:translate-y-0.5 transition-all duration-200"
style={{ position: 'relative', zIndex: 3100, pointerEvents: 'auto', padding: '0.5rem 0.75rem', backgroundColor: '#4f46e5', borderRadius: '1rem', border: '1px solid rgba(255,255,255,0.5)', cursor: 'pointer', touchAction: 'manipulation' }}
>
Revise
</button>
<button
type="button"
onClick={(e)=>{
e.preventDefault(); e.stopPropagation();
// Export single version content as simple .docx (inline changes off)
(async()=>{
try {
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
const latestReviser = (v.revisedBy || v.originalAuthor || username || 'User');
const filename = `${(task?.title||'Task').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_${(latestReviser||'User').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_v${v.versionNumber}.docx`;
const resp = await fetch(`${base}/api/refinity/track-changes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prev: '', current: v.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="inline-flex items-center justify-center gap-2 text-black text-sm font-medium rounded-2xl ring-1 ring-inset ring-white/50 active:translate-y-0.5 transition-all duration-200"
style={{ position: 'relative', zIndex: 3100, pointerEvents: 'auto', padding: '0.5rem 0.75rem', background: 'rgba(255,255,255,0.3)', borderRadius: '1rem', border: '1px solid rgba(255,255,255,0.5)' }}
>
Download
</button>
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setCompareUIOpen(true); if (!compareA) setCompareA(v.id); }}
className="inline-flex items-center justify-center gap-2 text-black text-sm font-medium rounded-2xl ring-1 ring-inset ring-white/50 active:translate-y-0.5 transition-all duration-200"
style={{ position: 'relative', zIndex: 3100, pointerEvents: 'auto', padding: '0.5rem 0.75rem', background: 'rgba(255,255,255,0.3)', borderRadius: '1rem', border: '1px solid rgba(255,255,255,0.5)' }}
>
Compare
</button>
{isAdmin && (
<button
type="button"
onClick={(e)=>{ e.preventDefault(); e.stopPropagation(); deleteVersion(v.id); }}
className="inline-flex items-center justify-center gap-2 text-white text-sm font-medium rounded-2xl ring-1 ring-inset ring-white/50 active:translate-y-0.5 transition-all duration-200"
style={{ position: 'relative', zIndex: 3100, pointerEvents: 'auto', padding: '0.5rem 0.75rem', backgroundColor: '#dc2626', borderRadius: '1rem', border: '1px solid rgba(255,255,255,0.5)' }}
>
Delete
</button>
)}
</div>
</div>
)}
</div>
</SwiperSlide>
);
})}
</SwiperRoot>
{/* 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-44 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 latestReviser = (b.revisedBy || b.originalAuthor || username || 'User');
const filename = `${(task?.title||'Task').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_${(latestReviser||'User').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_diff.docx`;
const body = { prev: a.content||'', current: b.content||'', filename, authorName: latestReviser };
const resp = await fetch(`${base}/api/refinity/track-changes-comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!resp.ok) throw new Error('Export failed');
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);
} catch {}
}} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">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 latestReviser = (b.revisedBy || b.originalAuthor || username || 'User');
const filename = `${(task?.title||'Task').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_${(latestReviser||'User').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_ooxml.docx`;
const body = { prev: a.content||'', current: b.content||'', filename, authorName: latestReviser };
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="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Side 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; }
/* 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">
<div className="relative max-w-5xl w-full rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200 p-6">
<div className="flex items-center justify-between mb-3">
<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 latestReviser = (b?.revisedBy || b?.originalAuthor || username || 'User');
const filename = `${(task?.title||'Task').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_${(latestReviser||'User').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_diff.docx`;
const body = { prev: (a.content||''), current: (b.content||''), filename, authorName: latestReviser };
const resp = await fetch(`${base}/api/refinity/track-changes-comments`, { 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 latestReviser = (b?.revisedBy || b?.originalAuthor || username || 'User');
const filename = `${(task?.title||'Task').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_${(latestReviser||'User').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_ooxml.docx`;
const body = { prev: (a.content||''), current: (b.content||''), filename, authorName: latestReviser };
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 (Side 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="prose prose-sm max-w-none text-gray-900" dangerouslySetInnerHTML={{ __html: compareDiffHtml }} />
</div>
</div>
)}
</div>
)}
{/* Stage: Preview full text */}
{stage === 'preview' && (
<PreviewPane
version={taskVersions.find(v => v.id === previewVersionId) || (flowIndex >= 0 ? (taskVersions[flowIndex] || null) : null)}
onBack={()=>setStage('flow')}
onEdit={()=>{ if (previewVersionId) { setCurrentVersionId(previewVersionId); setStage('editor'); } }}
compareInitially={showPreviewDiff}
/>
)}
{/* Stage 3: Editor */}
{stage === 'editor' && (
<EditorPane
source={task?.sourceText || ''}
initialTranslation={taskVersions.find(v => v.id === currentVersionId)?.content || ''}
onBack={()=>setStage('flow')}
onSave={handleSaveRevision}
taskTitle={task?.title || 'Task'}
username={username}
nextVersionNumber={((versions.filter(v=>v.taskId===(task?.id||'')).slice(-1)[0]?.versionNumber) || 0) + 1}
/>
)}
</div>
</div>
);
};
const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack: ()=>void; onSave: (text: string)=>void; taskTitle: string; username: string; nextVersionNumber: number }>=({ source, initialTranslation, onBack, onSave, taskTitle, username, nextVersionNumber })=>{
const [text, setText] = React.useState<string>(initialTranslation);
const [saving, setSaving] = React.useState(false);
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 revBtnRef = React.useRef<HTMLButtonElement | null>(null);
const sourceRef = React.useRef<HTMLDivElement>(null);
const [textareaHeight, setTextareaHeight] = React.useState('420px');
React.useEffect(() => {
if (sourceRef.current) {
const height = sourceRef.current.offsetHeight;
setTextareaHeight(`${height}px`);
}
}, [source]);
const save = async ()=>{
setSaving(true);
try {
onSave(text);
} finally {
setSaving(false);
}
};
const compareNow = async ()=>{
try {
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: initialTranslation || '', current: text || '' }) });
const data = await resp.json().catch(()=>({}));
if (!resp.ok) throw new Error(data?.error || 'Diff failed');
setDiffHtml(String(data?.html || ''));
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 = (username || 'User').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_');
const vNum = `v${nextVersionNumber}`;
const filename = `${taskNameSafe}_${userNameSafe}_${vNum}.docx`;
const resp = await fetch(`${base}/api/refinity/track-changes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prev: initialTranslation || '', current: text || '', filename }) });
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">Source</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">Translation</div>
<textarea
value={text}
onChange={(e)=>setText(e.target.value)}
className="relative z-10 w-full px-4 py-3 border border-ui-border rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white resize-y"
style={{
minHeight: textareaHeight,
height: textareaHeight,
resize: 'vertical'
}}
/>
<div className="mt-4 flex gap-3 relative">
<button onClick={save} disabled={saving} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-indigo-600/70 disabled:bg-gray-400 active:translate-y-0.5 transition-all duration-200">{saving? 'Saving…':'Save'}</button>
<div className="relative inline-block align-top">
<button ref={revBtnRef} onClick={async (e)=>{ e.preventDefault(); e.stopPropagation(); try { const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const filename=`${(taskTitle||'Task').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_${(username||'User').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}.docx`; const body={ current: text||'', filename }; const resp=await fetch(`${base}/api/refinity/export-plain`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body) }); if(!resp.ok) throw new Error('Export failed'); const blob=await resp.blob(); const url=window.URL.createObjectURL(blob); const link=document.createElement('a'); link.href=url; link.download=filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);} catch {} }} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-black ring-1 ring-inset ring-white/50 backdrop-blur-md bg-white/30 active:translate-y-0.5 transition-all duration-200">Download</button>
</div>
<button onClick={onBack} className="ml-auto relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-black ring-1 ring-inset ring-white/50 backdrop-blur-md bg-white/30 active:translate-y-0.5 transition-all duration-200">Back</button>
</div>
{showDiff && (
<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 text-gray-900 prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: diffHtml }} />
</div>
</div>
)}
</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="mb-3 text-gray-700 text-sm">Original: {version.originalAuthor} · Revised by: {version.revisedBy || '—'} (v{version.versionNumber})</div>
<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-6">
<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" />
{!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-indigo-600/70 active:translate-y-0.5 transition-all duration-200">Revise</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>
);
};