| 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; |
| content: string; |
| parentVersionId?: string; |
| }; |
|
|
| 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]); |
|
|
| |
| const [uploading, setUploading] = React.useState(false); |
| const fileInputRef = React.useRef<HTMLInputElement | null>(null); |
|
|
| |
| 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]); |
| |
| 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 {} |
| })(); |
| }, []); |
|
|
| |
| 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]); |
|
|
| |
| 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}`; |
| |
| 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(/\/$/, ''); |
| |
| 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)]; |
| |
| 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; } |
| |
| 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)]; |
| |
| setSelectedTaskId(randTask.id); |
| setVersions(vers); |
| setCurrentVersionId(pick.id); |
| |
| 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 { |
| |
| } |
| }; |
|
|
| 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(/\/$/, ''); |
| |
| 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> |
| ); |
| }; |
|
|
|
|
|
|