|
|
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 AnnotationCategory = |
|
|
| 'distortion' |
|
|
| 'omission' |
|
|
| 'register' |
|
|
| 'unidiomatic' |
|
|
| 'grammar' |
|
|
| 'spelling' |
|
|
| 'punctuation' |
|
|
| 'addition' |
|
|
| 'other'; |
|
|
|
|
|
type Annotation = { |
|
|
id: string; |
|
|
versionId: string; |
|
|
start: number; |
|
|
end: number; |
|
|
category: AnnotationCategory; |
|
|
comment?: string; |
|
|
correction?: string; |
|
|
createdBy?: string; |
|
|
createdAt: number; |
|
|
updatedAt: number; |
|
|
}; |
|
|
|
|
|
type Task = { |
|
|
id: string; |
|
|
title: string; |
|
|
sourceText: string; |
|
|
createdBy?: string; |
|
|
}; |
|
|
|
|
|
|
|
|
function toSafeName(input: string): string { |
|
|
|
|
|
return String(input || '') |
|
|
.replace(/[\\/:*?"<>|]+/g, '_') |
|
|
.replace(/[\u0000-\u001F\u007F]/g, '') |
|
|
.trim() |
|
|
.replace(/\s+/g, '_'); |
|
|
} |
|
|
function pad2(n: number): string { |
|
|
return n < 10 ? `0${n}` : String(n); |
|
|
} |
|
|
function yyyymmdd_hhmm(d = new Date()): string { |
|
|
const Y = d.getFullYear(); |
|
|
const M = pad2(d.getMonth() + 1); |
|
|
const D = pad2(d.getDate()); |
|
|
const h = pad2(d.getHours()); |
|
|
const m = pad2(d.getMinutes()); |
|
|
return `${Y}${M}${D}_${h}${m}`; |
|
|
} |
|
|
|
|
|
|
|
|
async function saveBlobToDisk(blob: Blob, filename: string) { |
|
|
const url = window.URL.createObjectURL(blob); |
|
|
const link = document.createElement('a'); |
|
|
link.href = url; |
|
|
link.download = filename; |
|
|
document.body.appendChild(link); |
|
|
link.click(); |
|
|
link.remove(); |
|
|
window.URL.revokeObjectURL(url); |
|
|
} |
|
|
function saveTextFallback(text: string, filename: string) { |
|
|
const blob = new Blob([text || ''], { type: 'text/plain;charset=utf-8' }); |
|
|
const fallbackName = filename.replace(/\.docx$/i, '') + '.txt'; |
|
|
return saveBlobToDisk(blob, fallbackName); |
|
|
} |
|
|
async function tryFetchDocx(url: string, body: any): Promise<Response> { |
|
|
return await fetch(url, { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify(body) |
|
|
}); |
|
|
} |
|
|
|
|
|
const mockTasks: Task[] = [ |
|
|
{ id: 't1', title: 'Refinity Demo Task 1', sourceText: 'The quick brown fox jumps over the lazy dog.' }, |
|
|
{ id: 't2', title: 'Refinity Demo Task 2', sourceText: 'To be, or not to be, that is the question.' }, |
|
|
]; |
|
|
|
|
|
const Refinity: React.FC = () => { |
|
|
|
|
|
const [isTutorialMode, setIsTutorialMode] = React.useState<boolean>(() => { |
|
|
try { |
|
|
return localStorage.getItem('refinityMode') === 'tutorial'; |
|
|
} catch { |
|
|
return false; |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
const checkTutorialMode = () => { |
|
|
try { |
|
|
setIsTutorialMode(localStorage.getItem('refinityMode') === 'tutorial'); |
|
|
} catch { |
|
|
setIsTutorialMode(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
checkTutorialMode(); |
|
|
|
|
|
window.addEventListener('storage', checkTutorialMode); |
|
|
|
|
|
const interval = setInterval(checkTutorialMode, 100); |
|
|
return () => { |
|
|
window.removeEventListener('storage', checkTutorialMode); |
|
|
clearInterval(interval); |
|
|
}; |
|
|
}, []); |
|
|
|
|
|
const getApiBase = React.useCallback((endpoint: string) => { |
|
|
|
|
|
const isLocalhost = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname); |
|
|
const base = isLocalhost ? 'http://localhost:5000' : (((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '')); |
|
|
|
|
|
try { |
|
|
const mode = localStorage.getItem('refinityMode'); |
|
|
if (mode === 'tutorial') { |
|
|
return `${base}/api/tutorial-refinity${endpoint}`; |
|
|
} |
|
|
} catch {} |
|
|
return `${base}/api/refinity${endpoint}`; |
|
|
}, []); |
|
|
|
|
|
const [stage, setStage] = React.useState<Stage>(() => { |
|
|
try { |
|
|
const h = String(window.location.hash || ''); |
|
|
const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : ''; |
|
|
const params = new URLSearchParams(q); |
|
|
const s = String(params.get('stage') || ''); |
|
|
if (s === 'flow' || s === 'preview' || s === 'editor' || s === 'task') return s as any; |
|
|
} catch {} |
|
|
return 'task'; |
|
|
}); |
|
|
const [tasks, setTasks] = React.useState<Task[]>([]); |
|
|
const [selectedTaskId, setSelectedTaskId] = React.useState<string>(() => { |
|
|
try { |
|
|
|
|
|
const isTutorial = localStorage.getItem('refinityMode') === 'tutorial'; |
|
|
if (isTutorial) { |
|
|
const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); |
|
|
if (weekNumber > 0) { |
|
|
const saved = localStorage.getItem(`tutorial_selected_task_week_${weekNumber}`); |
|
|
if (saved) return saved; |
|
|
} |
|
|
} |
|
|
|
|
|
const h = String(window.location.hash || ''); |
|
|
const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : ''; |
|
|
const params = new URLSearchParams(q); |
|
|
return String(params.get('taskId') || ''); |
|
|
} catch { return ''; } |
|
|
}); |
|
|
|
|
|
const userSelectedTaskIdRef = React.useRef<string | null>(null); |
|
|
const [versions, setVersions] = React.useState<Version[]>([]); |
|
|
const [currentVersionId, setCurrentVersionId] = React.useState<string | null>(() => { |
|
|
try { |
|
|
const h = String(window.location.hash || ''); |
|
|
const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : ''; |
|
|
const params = new URLSearchParams(q); |
|
|
return String(params.get('versionId') || '') || null; |
|
|
} catch { return null; } |
|
|
}); |
|
|
const [previewVersionId, setPreviewVersionId] = React.useState<string | null>(() => { |
|
|
try { |
|
|
const h = String(window.location.hash || ''); |
|
|
const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : ''; |
|
|
const params = new URLSearchParams(q); |
|
|
return String(params.get('previewId') || '') || null; |
|
|
} catch { return null; } |
|
|
}); |
|
|
const [isFullscreen, setIsFullscreen] = React.useState<boolean>(() => { |
|
|
try { |
|
|
const h = String(window.location.hash || ''); |
|
|
const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : ''; |
|
|
const params = new URLSearchParams(q); |
|
|
return params.get('fullscreen') === '1'; |
|
|
} catch { return false; } |
|
|
}); |
|
|
const [compareUIOpen, setCompareUIOpen] = React.useState<boolean>(false); |
|
|
const [compareA, setCompareA] = React.useState<string>(''); |
|
|
const [compareB, setCompareB] = React.useState<string>(''); |
|
|
const [compareModalOpen, setCompareModalOpen] = React.useState<boolean>(false); |
|
|
const [lastDiffIds, setLastDiffIds] = React.useState<{ a?: string; b?: string }>({}); |
|
|
const [compareDiffHtml, setCompareDiffHtml] = React.useState<string>(''); |
|
|
const [compareLoading, setCompareLoading] = React.useState<boolean>(false); |
|
|
const [compareDownloadOpen, setCompareDownloadOpen] = React.useState<boolean>(false); |
|
|
const [compareMenuPos, setCompareMenuPos] = React.useState<{ left: number; top: number } | null>(null); |
|
|
const compareBtnRef = React.useRef<HTMLButtonElement | null>(null); |
|
|
const [revDownloadOpen, setRevDownloadOpen] = React.useState<boolean>(false); |
|
|
|
|
|
React.useEffect(() => { |
|
|
try { |
|
|
const ua = navigator.userAgent || ''; |
|
|
const isChrome = /Chrome\//.test(ua) && !/Edg\//.test(ua) && !/OPR\//.test(ua); |
|
|
const root = document.documentElement; |
|
|
if (isChrome) root.classList.add('is-chrome'); |
|
|
else root.classList.remove('is-chrome'); |
|
|
} catch {} |
|
|
}, []); |
|
|
|
|
|
const restoringRef = React.useRef<boolean>(false); |
|
|
const appliedInitialRouteRef = React.useRef<boolean>(false); |
|
|
const initialRouteRef = React.useRef<{ stage?: Stage; taskId?: string; versionId?: string; previewId?: string; fullscreen?: boolean } | null>(null); |
|
|
const parseRouteHash = React.useCallback((): Record<string, string> => { |
|
|
try { |
|
|
const h = String(window.location.hash || ''); |
|
|
const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : ''; |
|
|
const out: Record<string, string> = {}; |
|
|
q.split('&').forEach(p => { |
|
|
const [k, v] = p.split('='); |
|
|
if (!k) return; |
|
|
out[decodeURIComponent(k)] = decodeURIComponent(v || ''); |
|
|
}); |
|
|
return out; |
|
|
} catch { return {}; } |
|
|
}, []); |
|
|
const writeRouteHash = React.useCallback((s: Partial<{ stage: Stage; taskId: string; versionId: string; previewId: string; fullscreen: string }>) => { |
|
|
try { |
|
|
if (restoringRef.current) return; |
|
|
const params = new URLSearchParams(); |
|
|
const current = parseRouteHash(); |
|
|
const merged: Record<string, string> = { ...current, ...Object.fromEntries(Object.entries(s).filter(([,v]) => v !== undefined && v !== null).map(([k,v]) => [k, String(v)])) }; |
|
|
Object.entries(merged).forEach(([k, v]) => { if (v) params.set(k, v); }); |
|
|
const next = `#/dr?${params.toString()}`; |
|
|
if (String(window.location.hash) !== next) { |
|
|
window.location.hash = next; |
|
|
} |
|
|
} catch {} |
|
|
}, [parseRouteHash]); |
|
|
|
|
|
const [showAnnotations, setShowAnnotations] = React.useState<boolean>(true); |
|
|
const [annotationPopover, setAnnotationPopover] = React.useState<{ left: number; top: number; versionId: string; range: { start: number; end: number } } | null>(null); |
|
|
const [annotationModalOpen, setAnnotationModalOpen] = React.useState<boolean>(false); |
|
|
const [editingAnnotation, setEditingAnnotation] = React.useState<Annotation | null>(null); |
|
|
const [modalCategory, setModalCategory] = React.useState<AnnotationCategory>('distortion'); |
|
|
const [modalComment, setModalComment] = React.useState<string>(''); |
|
|
const [username] = React.useState<string>(() => { |
|
|
try { |
|
|
const u = localStorage.getItem('user'); |
|
|
const name = u ? (JSON.parse(u)?.name || JSON.parse(u)?.email || 'Anonymous') : 'Anonymous'; |
|
|
return String(name); |
|
|
} catch { |
|
|
return 'Anonymous'; |
|
|
} |
|
|
}); |
|
|
const getIsAdmin = React.useCallback(() => { |
|
|
try { |
|
|
const u = localStorage.getItem('user'); |
|
|
const role = u ? (JSON.parse(u)?.role || '') : ''; |
|
|
const viewMode = (localStorage.getItem('viewMode') || 'auto').toString().toLowerCase(); |
|
|
if (viewMode === 'student') return false; |
|
|
return String(role).toLowerCase() === 'admin'; |
|
|
} catch { return false; } |
|
|
}, []); |
|
|
const [isAdmin, setIsAdmin] = React.useState<boolean>(() => getIsAdmin()); |
|
|
React.useEffect(() => { |
|
|
const refresh = () => setIsAdmin(getIsAdmin()); |
|
|
window.addEventListener('storage', refresh); |
|
|
const id = window.setInterval(refresh, 500); |
|
|
return () => { window.removeEventListener('storage', refresh); window.clearInterval(id); }; |
|
|
}, [getIsAdmin]); |
|
|
|
|
|
|
|
|
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 [inputTab, setInputTab] = React.useState<'paste' | 'upload'>('paste'); |
|
|
const [editingVersionId, setEditingVersionId] = React.useState<string | null>(null); |
|
|
const [editingTaskOpen, setEditingTaskOpen] = React.useState<boolean>(false); |
|
|
const [editTaskTitle, setEditTaskTitle] = React.useState<string>(''); |
|
|
const [editTaskSource, setEditTaskSource] = React.useState<string>(''); |
|
|
const [savingTaskEdit, setSavingTaskEdit] = React.useState<boolean>(false); |
|
|
|
|
|
const task = React.useMemo(() => tasks.find(t => t.id === selectedTaskId) || tasks[0], [tasks, selectedTaskId]); |
|
|
const taskVersions = React.useMemo(() => versions.filter(v => v.taskId === (task?.id || '')), [versions, task?.id]); |
|
|
|
|
|
|
|
|
|
|
|
const getTutorialMode = React.useCallback(() => { |
|
|
try { |
|
|
return localStorage.getItem('refinityMode') === 'tutorial'; |
|
|
} catch { |
|
|
return false; |
|
|
} |
|
|
}, []); |
|
|
|
|
|
const TASKS_CACHE_KEY = React.useMemo(() => { |
|
|
const isTut = getTutorialMode(); |
|
|
return isTut ? 'tutorial_refinity_tasks_cache_v1' : 'refinity_tasks_cache_v1'; |
|
|
}, [getTutorialMode]); |
|
|
|
|
|
const versionsCacheKey = React.useCallback((taskId: string) => { |
|
|
const isTut = getTutorialMode(); |
|
|
return isTut ? `tutorial_refinity_versions_cache_${taskId}_v1` : `refinity_versions_cache_${taskId}_v1`; |
|
|
}, [getTutorialMode]); |
|
|
|
|
|
React.useEffect(() => { |
|
|
(async () => { |
|
|
try { |
|
|
|
|
|
if (!initialRouteRef.current) { |
|
|
const params = parseRouteHash(); |
|
|
const desiredStage = (params.stage as Stage) || undefined; |
|
|
const fullscreen = params.fullscreen === '1'; |
|
|
initialRouteRef.current = { stage: desiredStage, taskId: params.taskId, versionId: params.versionId, previewId: params.previewId, fullscreen }; |
|
|
if (fullscreen) setIsFullscreen(true); |
|
|
} |
|
|
|
|
|
try { |
|
|
const cached = JSON.parse(sessionStorage.getItem(TASKS_CACHE_KEY) || '[]'); |
|
|
if (Array.isArray(cached) && cached.length && tasks.length === 0) { |
|
|
setTasks(cached); |
|
|
|
|
|
setSelectedTaskId(prev => { |
|
|
if (prev) return prev; |
|
|
const initTaskId = initialRouteRef.current?.taskId; |
|
|
const isTutorial = getTutorialMode(); |
|
|
if (initTaskId && cached.some((t:any)=>t.id===initTaskId)) { |
|
|
if (isTutorial) { |
|
|
const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); |
|
|
if (weekNumber > 0) { |
|
|
localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, initTaskId); |
|
|
} |
|
|
} |
|
|
return initTaskId; |
|
|
} else if (cached.length) { |
|
|
if (isTutorial) { |
|
|
const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); |
|
|
if (weekNumber > 0) { |
|
|
const savedTaskId = localStorage.getItem(`tutorial_selected_task_week_${weekNumber}`); |
|
|
if (savedTaskId && cached.some((t:any)=>t.id===savedTaskId)) { |
|
|
return savedTaskId; |
|
|
} else { |
|
|
localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, cached[0].id); |
|
|
} |
|
|
} |
|
|
} |
|
|
return cached[0].id; |
|
|
} |
|
|
return prev; |
|
|
}); |
|
|
} |
|
|
} catch {} |
|
|
|
|
|
let tasksUrl = getApiBase('/tasks'); |
|
|
if (getTutorialMode()) { |
|
|
const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); |
|
|
if (weekNumber > 0) { |
|
|
tasksUrl += `?weekNumber=${weekNumber}`; |
|
|
} |
|
|
} |
|
|
|
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}'); |
|
|
const headers: any = {}; |
|
|
if (user.name) headers['x-user-name'] = user.name; |
|
|
if (user.email) headers['x-user-email'] = user.email; |
|
|
if (user.role === 'admin') { |
|
|
headers['x-user-role'] = 'admin'; |
|
|
headers['user-role'] = 'admin'; |
|
|
} |
|
|
const resp = await fetch(tasksUrl, { headers }); |
|
|
const data: any[] = await resp.json().catch(()=>[]); |
|
|
|
|
|
let normalized: Task[] = Array.isArray(data) ? data.map(d => ({ id: d._id, title: d.title, sourceText: d.sourceText, createdBy: d.createdBy })) : []; |
|
|
const currentTutorialMode = getTutorialMode(); |
|
|
if (currentTutorialMode) { |
|
|
|
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}'); |
|
|
const isAdmin = user.role === 'admin'; |
|
|
if (!isAdmin) { |
|
|
|
|
|
normalized = normalized.filter(t => { |
|
|
|
|
|
|
|
|
return true; |
|
|
}); |
|
|
} |
|
|
} |
|
|
setTasks(normalized); |
|
|
try { sessionStorage.setItem(TASKS_CACHE_KEY, JSON.stringify(normalized)); } catch {} |
|
|
|
|
|
|
|
|
setSelectedTaskId(prev => { |
|
|
|
|
|
const currentUserSelection = userSelectedTaskIdRef.current; |
|
|
if (currentUserSelection && normalized.some(t => t.id === currentUserSelection)) { |
|
|
return currentUserSelection; |
|
|
} |
|
|
|
|
|
if (prev && normalized.some(t => t.id === prev)) { |
|
|
return prev; |
|
|
} |
|
|
|
|
|
const initTaskId = initialRouteRef.current?.taskId; |
|
|
if (normalized.length) { |
|
|
if (initTaskId && normalized.some(t => t.id === initTaskId)) { |
|
|
if (currentTutorialMode) { |
|
|
const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); |
|
|
if (weekNumber > 0) { |
|
|
localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, initTaskId); |
|
|
} |
|
|
} |
|
|
return initTaskId; |
|
|
} else { |
|
|
|
|
|
if (currentTutorialMode) { |
|
|
const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); |
|
|
if (weekNumber > 0) { |
|
|
const savedTaskId = localStorage.getItem(`tutorial_selected_task_week_${weekNumber}`); |
|
|
if (savedTaskId && normalized.some(t => t.id === savedTaskId)) { |
|
|
return savedTaskId; |
|
|
} else { |
|
|
localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, normalized[0].id); |
|
|
} |
|
|
} |
|
|
} |
|
|
return normalized[0].id; |
|
|
} |
|
|
} |
|
|
return prev; |
|
|
}); |
|
|
} catch {} |
|
|
})(); |
|
|
}, [getApiBase, TASKS_CACHE_KEY, tasks.length, getTutorialMode]); |
|
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
(async () => { |
|
|
if (!task?.id) { setVersions([]); return; } |
|
|
try { |
|
|
|
|
|
try { |
|
|
const cached = JSON.parse(sessionStorage.getItem(versionsCacheKey(task.id)) || '[]'); |
|
|
|
|
|
let filteredCache = cached; |
|
|
const currentTutorialMode = getTutorialMode(); |
|
|
if (currentTutorialMode && !isAdmin) { |
|
|
filteredCache = cached.filter((v: any) => { |
|
|
|
|
|
return v.versionNumber === 1 || v.originalAuthor === username || v.revisedBy === username; |
|
|
}); |
|
|
} |
|
|
if (Array.isArray(filteredCache) && filteredCache.length) { |
|
|
setVersions(filteredCache); |
|
|
|
|
|
if (stage === 'editor' && !currentVersionId) { |
|
|
setCurrentVersionId(filteredCache[filteredCache.length - 1]?.id || null); |
|
|
} |
|
|
} |
|
|
} catch {} |
|
|
|
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}'); |
|
|
const headers: any = {}; |
|
|
if (user.name) headers['x-user-name'] = user.name; |
|
|
if (user.email) headers['x-user-email'] = user.email; |
|
|
if (user.role === 'admin') { |
|
|
headers['x-user-role'] = 'admin'; |
|
|
headers['user-role'] = 'admin'; |
|
|
} |
|
|
const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(task.id)}/versions`), { headers }); |
|
|
const data: any[] = await resp.json().catch(()=>[]); |
|
|
let normalized: Version[] = Array.isArray(data) ? data.map(d => ({ id: d._id, taskId: d.taskId, originalAuthor: d.originalAuthor, revisedBy: d.revisedBy, versionNumber: d.versionNumber, content: d.content, parentVersionId: d.parentVersionId })) : []; |
|
|
|
|
|
const currentTutorialMode = getTutorialMode(); |
|
|
if (currentTutorialMode && !isAdmin) { |
|
|
normalized = normalized.filter(v => { |
|
|
|
|
|
return v.versionNumber === 1 || v.originalAuthor === username || v.revisedBy === username; |
|
|
}); |
|
|
} |
|
|
setVersions(normalized); |
|
|
try { sessionStorage.setItem(versionsCacheKey(task.id), JSON.stringify(normalized)); } catch {} |
|
|
|
|
|
const init = initialRouteRef.current; |
|
|
if (!appliedInitialRouteRef.current && init?.stage && normalized.length && (!init.taskId || init.taskId === task.id)) { |
|
|
restoringRef.current = true; |
|
|
try { |
|
|
if (init.stage === 'editor' && init.versionId && normalized.some(v => v.id === init.versionId)) { |
|
|
setCurrentVersionId(init.versionId); |
|
|
setStage('editor'); |
|
|
} else if (init.stage === 'preview' && init.previewId && normalized.some(v => v.id === init.previewId)) { |
|
|
setPreviewVersionId(init.previewId); |
|
|
setStage('preview'); |
|
|
} else if (init.stage === 'editor') { |
|
|
|
|
|
setCurrentVersionId(normalized[normalized.length-1]?.id || null); |
|
|
setStage('editor'); |
|
|
} else if (init.stage === 'preview') { |
|
|
setPreviewVersionId(normalized[normalized.length-1]?.id || null); |
|
|
setStage('preview'); |
|
|
} else if (init.stage === 'flow') { |
|
|
setStage('flow'); |
|
|
} |
|
|
} finally { |
|
|
|
|
|
setTimeout(() => { restoringRef.current = false; }, 50); |
|
|
appliedInitialRouteRef.current = true; |
|
|
initialRouteRef.current = null; |
|
|
} |
|
|
} else { |
|
|
|
|
|
if (!currentVersionId) setCurrentVersionId(normalized.length ? normalized[normalized.length-1].id : null); |
|
|
} |
|
|
} catch { setVersions([]); } |
|
|
})(); |
|
|
}, [task?.id, getApiBase, getTutorialMode, versionsCacheKey, isAdmin]); |
|
|
|
|
|
const deleteVersion = React.useCallback(async (versionId: string) => { |
|
|
try { |
|
|
const ok = window.confirm('Delete this version? This cannot be undone.'); |
|
|
if (!ok) return; |
|
|
const resp = await fetch(getApiBase(`/versions/${encodeURIComponent(versionId)}`), { |
|
|
method: 'DELETE', |
|
|
headers: { 'x-user-name': username, ...(isAdmin ? { 'x-user-role': 'admin' } : {}) } |
|
|
}); |
|
|
if (!resp.ok) throw new Error('Delete failed'); |
|
|
setVersions(prev => prev.filter(v => v.id !== versionId)); |
|
|
} catch {} |
|
|
}, [isAdmin, username]); |
|
|
|
|
|
const saveEditedVersion = React.useCallback(async (versionId: string, newContent: string) => { |
|
|
const resp = await fetch(getApiBase(`/versions/${encodeURIComponent(versionId)}`), { |
|
|
method: 'PUT', |
|
|
headers: { 'Content-Type': 'application/json', 'x-user-name': username }, |
|
|
body: JSON.stringify({ content: newContent }) |
|
|
}); |
|
|
const updated = await resp.json().catch(()=>({})); |
|
|
if (!resp.ok) throw new Error(updated?.error || 'Update failed'); |
|
|
setVersions(prev => prev.map(v => v.id === versionId ? { ...v, content: updated.content } as any : v)); |
|
|
setEditingVersionId(null); |
|
|
setStage('flow'); |
|
|
}, [username]); |
|
|
|
|
|
const openEditTask = React.useCallback(() => { |
|
|
const t = tasks.find(t => t.id === selectedTaskId); |
|
|
if (!t) return; |
|
|
setEditTaskTitle(t.title || ''); |
|
|
setEditTaskSource(t.sourceText || ''); |
|
|
setEditingTaskOpen(true); |
|
|
}, [tasks, selectedTaskId]); |
|
|
|
|
|
const saveTaskEdit = React.useCallback(async () => { |
|
|
if (!selectedTaskId) return; |
|
|
setSavingTaskEdit(true); |
|
|
try { |
|
|
const token = localStorage.getItem('token'); |
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}'); |
|
|
const viewMode = (localStorage.getItem('viewMode') || 'auto'); |
|
|
const effectiveRole = viewMode === 'student' ? 'student' : (user.role || 'visitor'); |
|
|
|
|
|
const headers: any = { 'Content-Type': 'application/json', 'x-user-name': username }; |
|
|
if (token) { |
|
|
headers['Authorization'] = `Bearer ${token}`; |
|
|
} |
|
|
headers['x-user-role'] = effectiveRole; |
|
|
headers['user-role'] = effectiveRole; |
|
|
|
|
|
const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(selectedTaskId)}`), { |
|
|
method: 'PUT', |
|
|
headers, |
|
|
body: JSON.stringify({ title: editTaskTitle, sourceText: editTaskSource }) |
|
|
}); |
|
|
const updated = await resp.json().catch(()=>({})); |
|
|
if (!resp.ok) throw new Error(updated?.error || 'Update failed'); |
|
|
setTasks(prev => prev.map(t => t.id === selectedTaskId ? { ...t, title: updated.title, sourceText: updated.sourceText } : t)); |
|
|
setEditingTaskOpen(false); |
|
|
} catch (e) { |
|
|
alert('Failed to update task'); |
|
|
} finally { |
|
|
setSavingTaskEdit(false); |
|
|
} |
|
|
}, [selectedTaskId, editTaskTitle, editTaskSource, username, getApiBase, isAdmin]); |
|
|
|
|
|
const [flowIndex, setFlowIndex] = React.useState<number>(0); |
|
|
|
|
|
React.useEffect(() => { |
|
|
writeRouteHash({ |
|
|
stage, |
|
|
taskId: task?.id || '', |
|
|
versionId: currentVersionId || '', |
|
|
previewId: previewVersionId || '', |
|
|
fullscreen: isFullscreen ? '1' : '0' |
|
|
}); |
|
|
}, [stage, task?.id, currentVersionId, previewVersionId, isFullscreen, writeRouteHash]); |
|
|
const focusedIndex = React.useMemo(() => { |
|
|
const idx = taskVersions.findIndex(v => v.id === currentVersionId); |
|
|
if (idx >= 0) return idx; |
|
|
return Math.min(Math.max(taskVersions.length - 1, 0), flowIndex); |
|
|
}, [taskVersions, currentVersionId, flowIndex]); |
|
|
const centerVersionId = React.useMemo(() => (taskVersions[flowIndex]?.id) || '', [taskVersions, flowIndex]); |
|
|
const currentVersion = React.useMemo(() => taskVersions.find(v => v.id === currentVersionId) || null, [taskVersions, currentVersionId]); |
|
|
const previewVersion = React.useMemo(() => taskVersions.find(v => v.id === previewVersionId) || null, [taskVersions, previewVersionId]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const reviseBtnRefs = React.useRef<Record<string, HTMLButtonElement | null>>({}); |
|
|
const [reviseOverlay, setReviseOverlay] = React.useState<{ left: number; top: number; width: number; height: number; vid: string } | null>(null); |
|
|
const updateReviseOverlay = React.useCallback(() => { |
|
|
try { |
|
|
const v = taskVersions[flowIndex]; |
|
|
if (!v) { setReviseOverlay(null); return; } |
|
|
const el = reviseBtnRefs.current[v.id || '']; |
|
|
if (!el) { setReviseOverlay(null); return; } |
|
|
const r = el.getBoundingClientRect(); |
|
|
if (r.width === 0 || r.height === 0) { setReviseOverlay(null); return; } |
|
|
setReviseOverlay({ left: Math.max(0, r.left), top: Math.max(0, r.top), width: r.width, height: r.height, vid: v.id }); |
|
|
} catch { setReviseOverlay(null); } |
|
|
}, [taskVersions, flowIndex]); |
|
|
React.useLayoutEffect(() => { updateReviseOverlay(); }, [updateReviseOverlay, isFullscreen, compareUIOpen, revDownloadOpen]); |
|
|
React.useEffect(() => { |
|
|
|
|
|
let raf1 = 0, raf2 = 0, raf3 = 0; |
|
|
const tick = () => updateReviseOverlay(); |
|
|
raf1 = window.requestAnimationFrame(() => { |
|
|
tick(); |
|
|
raf2 = window.requestAnimationFrame(() => { |
|
|
tick(); |
|
|
raf3 = window.requestAnimationFrame(() => tick()); |
|
|
}); |
|
|
}); |
|
|
const t = window.setTimeout(tick, 260); |
|
|
return () => { |
|
|
window.cancelAnimationFrame(raf1); |
|
|
window.cancelAnimationFrame(raf2); |
|
|
window.cancelAnimationFrame(raf3); |
|
|
window.clearTimeout(t); |
|
|
}; |
|
|
}, [flowIndex, centerVersionId, updateReviseOverlay]); |
|
|
React.useEffect(() => { |
|
|
const onResize = () => updateReviseOverlay(); |
|
|
const onScroll = () => updateReviseOverlay(); |
|
|
window.addEventListener('resize', onResize, { passive: true } as any); |
|
|
window.addEventListener('scroll', onScroll, { passive: true } as any); |
|
|
return () => { |
|
|
window.removeEventListener('resize', onResize as any); |
|
|
window.removeEventListener('scroll', onScroll as any); |
|
|
}; |
|
|
}, [updateReviseOverlay]); |
|
|
|
|
|
const uploadDocx = async (file: File) => { |
|
|
setUploading(true); |
|
|
try { |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); |
|
|
const form = new FormData(); |
|
|
form.append('file', file); |
|
|
|
|
|
const resp = await fetch(`${base}${getApiBase('/parse')}`, { method: 'POST', body: form }); |
|
|
const data = await resp.json().catch(()=>({})); |
|
|
if (!resp.ok) throw new Error(data?.error || 'Failed to parse document'); |
|
|
const baseText = String(data?.text || '').trim() || `Uploaded translation by ${username}`; |
|
|
|
|
|
const resp2 = await fetch(`${base}${getApiBase(`/tasks/${encodeURIComponent(task?.id || '')}/versions`)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ originalAuthor: username, revisedBy: undefined, content: baseText }) }); |
|
|
const saved = await resp2.json().catch(()=>({})); |
|
|
if (!resp2.ok) throw new Error(saved?.error || 'Failed to save version'); |
|
|
const newVersion: Version = { id: saved._id, taskId: saved.taskId, originalAuthor: saved.originalAuthor, revisedBy: saved.revisedBy, versionNumber: saved.versionNumber, content: saved.content, parentVersionId: saved.parentVersionId }; |
|
|
setVersions(prev => [...prev, newVersion]); |
|
|
|
|
|
try { |
|
|
const cacheKey = versionsCacheKey(task?.id || ''); |
|
|
const cached = JSON.parse(sessionStorage.getItem(cacheKey) || '[]'); |
|
|
const updated = [...cached, newVersion]; |
|
|
sessionStorage.setItem(cacheKey, JSON.stringify(updated)); |
|
|
} catch {} |
|
|
setCurrentVersionId(newVersion.id); |
|
|
setStage('flow'); |
|
|
} catch (e) { |
|
|
console.error('Error uploading document:', e); |
|
|
} finally { |
|
|
setUploading(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const ANNO_STORE_KEY = 'refinity_annotations_v1'; |
|
|
const loadAnnotations = React.useCallback((): Annotation[] => { |
|
|
try { |
|
|
const raw = localStorage.getItem(ANNO_STORE_KEY); |
|
|
const arr = raw ? JSON.parse(raw) : []; |
|
|
return Array.isArray(arr) ? arr : []; |
|
|
} catch { return []; } |
|
|
}, []); |
|
|
const saveAnnotations = React.useCallback((list: Annotation[]) => { |
|
|
try { localStorage.setItem(ANNO_STORE_KEY, JSON.stringify(list)); } catch {} |
|
|
}, []); |
|
|
const [annotations, setAnnotations] = React.useState<Annotation[]>(() => loadAnnotations()); |
|
|
React.useEffect(() => { saveAnnotations(annotations); }, [annotations, saveAnnotations]); |
|
|
const annoForVersion = React.useCallback((versionId: string) => annotations.filter(a => a.versionId === versionId), [annotations]); |
|
|
const addAnnotation = React.useCallback((a: Annotation) => setAnnotations(prev => [...prev, a]), []); |
|
|
const updateAnnotation = React.useCallback((a: Annotation) => setAnnotations(prev => prev.map(x => x.id === a.id ? a : x)), []); |
|
|
const deleteAnnotationById = React.useCallback((id: string) => setAnnotations(prev => prev.filter(a => a.id !== id)), []); |
|
|
|
|
|
const CATEGORY_LABELS: Record<AnnotationCategory, string> = { |
|
|
distortion: 'Distortion', |
|
|
omission: 'Unjustified omission', |
|
|
register: 'Inappropriate register', |
|
|
unidiomatic: 'Unidiomatic expression', |
|
|
grammar: 'Error of grammar, syntax', |
|
|
spelling: 'Error of spelling', |
|
|
punctuation: 'Error of punctuation', |
|
|
addition: 'Unjustified addition', |
|
|
other: 'Other', |
|
|
}; |
|
|
const CATEGORY_CLASS: Record<AnnotationCategory, string> = { |
|
|
distortion: 'bg-rose-100', |
|
|
omission: 'bg-amber-100', |
|
|
register: 'bg-indigo-100', |
|
|
unidiomatic: 'bg-purple-100', |
|
|
grammar: 'bg-blue-100', |
|
|
spelling: 'bg-green-100', |
|
|
punctuation: 'bg-teal-100', |
|
|
addition: 'bg-pink-100', |
|
|
other: 'bg-gray-100', |
|
|
}; |
|
|
function escapeHtml(s: string): string { |
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
|
|
} |
|
|
function renderAnnotatedHtml(text: string, versionId: string, enabled: boolean): string { |
|
|
if (!enabled) return escapeHtml(text); |
|
|
const list = annoForVersion(versionId).slice().sort((a,b)=>a.start-b.start); |
|
|
if (!list.length) return escapeHtml(text); |
|
|
let html = ''; |
|
|
let pos = 0; |
|
|
for (const a of list) { |
|
|
const start = Math.max(0, Math.min(a.start, text.length)); |
|
|
const end = Math.max(start, Math.min(a.end, text.length)); |
|
|
if (pos < start) html += escapeHtml(text.slice(pos, start)); |
|
|
const label = CATEGORY_LABELS[a.category] || 'Note'; |
|
|
const cls = CATEGORY_CLASS[a.category] || 'bg-gray-100 ring-gray-200'; |
|
|
const span = escapeHtml(text.slice(start, end)) || ' '; |
|
|
html += `<span data-anno-id="${a.id}" class="inline rounded-[6px] ring-1 ${cls} px-0.5" title="${label}${a.comment ? ': '+escapeHtml(a.comment) : ''}">${span}</span>`; |
|
|
pos = end; |
|
|
} |
|
|
if (pos < text.length) html += escapeHtml(text.slice(pos)); |
|
|
return html; |
|
|
} |
|
|
function rangeToOffsets(container: HTMLElement, range: Range, fullText: string): { start: number; end: number } { |
|
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null); |
|
|
let start = -1; |
|
|
let end = -1; |
|
|
let charCount = 0; |
|
|
while (walker.nextNode()) { |
|
|
const node = walker.currentNode as Text; |
|
|
const text = node.nodeValue || ''; |
|
|
if (node === range.startContainer) start = charCount + range.startOffset; |
|
|
if (node === range.endContainer) end = charCount + range.endOffset; |
|
|
charCount += text.length; |
|
|
} |
|
|
if (start < 0 || end < 0) { |
|
|
const sel = range.toString(); |
|
|
const idx = fullText.indexOf(sel); |
|
|
if (idx >= 0) { start = idx; end = idx + sel.length; } |
|
|
} |
|
|
start = Math.max(0, Math.min(start, fullText.length)); |
|
|
end = Math.max(start, Math.min(end, fullText.length)); |
|
|
return { start, end }; |
|
|
} |
|
|
function getSelectionPopoverPosition(range: Range, container: HTMLElement): { left: number; top: number } { |
|
|
const rect = range.getBoundingClientRect(); |
|
|
const crect = container.getBoundingClientRect(); |
|
|
const left = Math.max(8, rect.right - crect.left + 8); |
|
|
const top = Math.max(8, rect.top - crect.top - 8); |
|
|
return { left, top }; |
|
|
} |
|
|
|
|
|
|
|
|
const textRefs = React.useRef<{ [id: string]: HTMLDivElement | null }>({}); |
|
|
const [overflowMap, setOverflowMap] = React.useState<{ [id: string]: boolean }>({}); |
|
|
const recomputeOverflow = React.useCallback(() => { |
|
|
const next: { [id: string]: boolean } = {}; |
|
|
try { |
|
|
(versions || []).forEach((v) => { |
|
|
const el = textRefs.current[v.id]; |
|
|
if (el) next[v.id] = el.scrollHeight > el.clientHeight + 1; |
|
|
}); |
|
|
} catch {} |
|
|
setOverflowMap(next); |
|
|
}, [versions]); |
|
|
React.useEffect(() => { |
|
|
recomputeOverflow(); |
|
|
}, [recomputeOverflow, flowIndex, isFullscreen]); |
|
|
React.useEffect(() => { |
|
|
const id = window.setTimeout(() => recomputeOverflow(), 0); |
|
|
return () => window.clearTimeout(id); |
|
|
}, [versions, flowIndex, isFullscreen, recomputeOverflow]); |
|
|
React.useEffect(() => { |
|
|
const onResize = () => recomputeOverflow(); |
|
|
window.addEventListener('resize', onResize); |
|
|
const id = window.setInterval(onResize, 600); |
|
|
return () => { window.removeEventListener('resize', onResize); window.clearInterval(id); }; |
|
|
}, [recomputeOverflow]); |
|
|
|
|
|
const uploadSourceDoc = async (file: File) => { |
|
|
setAddingSourceUploading(true); |
|
|
try { |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); |
|
|
const form = new FormData(); |
|
|
form.append('file', file); |
|
|
const parseEndpoint = getTutorialMode() ? '/api/tutorial-refinity/parse' : '/api/refinity/parse'; |
|
|
const resp = await fetch(`${base}${parseEndpoint}`, { method: 'POST', body: form }); |
|
|
const data = await resp.json().catch(()=>({})); |
|
|
if (!resp.ok) throw new Error(data?.error || 'Failed to parse document'); |
|
|
setNewTaskSource(String(data?.text || '')); |
|
|
} finally { |
|
|
setAddingSourceUploading(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
const saveNewTask = async () => { |
|
|
const title = newTaskTitle.trim(); |
|
|
const src = newTaskSource.trim(); |
|
|
if (!title || !src) return; |
|
|
try { |
|
|
const body = { title, sourceText: src, createdBy: username }; |
|
|
if (getTutorialMode()) { |
|
|
|
|
|
const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); |
|
|
(body as any).weekNumber = weekNumber; |
|
|
} |
|
|
const token = localStorage.getItem('token'); |
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}'); |
|
|
const viewMode = (localStorage.getItem('viewMode') || 'auto'); |
|
|
const effectiveRole = viewMode === 'student' ? 'student' : (user.role || 'visitor'); |
|
|
|
|
|
const headers: any = { 'Content-Type': 'application/json', 'x-user-name': username }; |
|
|
if (token) { |
|
|
headers['Authorization'] = `Bearer ${token}`; |
|
|
} |
|
|
|
|
|
headers['x-user-role'] = effectiveRole; |
|
|
headers['user-role'] = effectiveRole; |
|
|
|
|
|
const resp = await fetch(getApiBase('/tasks'), { method: 'POST', headers, body: JSON.stringify(body) }); |
|
|
const saved = await resp.json().catch(()=>({})); |
|
|
if (!resp.ok) throw new Error(saved?.error || 'Save failed'); |
|
|
const t: Task = { id: saved._id, title: saved.title, sourceText: saved.sourceText, createdBy: saved.createdBy }; |
|
|
setTasks(prev => [...prev, t]); |
|
|
setSelectedTaskId(t.id); |
|
|
setShowAddTask(false); |
|
|
setNewTaskTitle(''); |
|
|
setNewTaskSource(''); |
|
|
} catch {} |
|
|
}; |
|
|
|
|
|
const assignRandom = async () => { |
|
|
try { |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); |
|
|
|
|
|
const respTasks = await fetch(`${base}${getApiBase('/tasks')}`); |
|
|
const tasksData: any[] = await respTasks.json().catch(()=>[]); |
|
|
const taskList: Task[] = Array.isArray(tasksData) ? tasksData.map(d => ({ id: d._id, title: d.title, sourceText: d.sourceText, createdBy: d.createdBy })) : []; |
|
|
if (!taskList.length) { setTaskStageNote('No tasks available yet.'); return; } |
|
|
const randTask = taskList[Math.floor(Math.random() * taskList.length)]; |
|
|
|
|
|
const respVers = await fetch(`${base}${getApiBase(`/tasks/${encodeURIComponent(randTask.id)}/versions`)}`); |
|
|
const versData: any[] = await respVers.json().catch(()=>[]); |
|
|
const vers: Version[] = Array.isArray(versData) ? versData.map(d => ({ id: d._id, taskId: d.taskId, originalAuthor: d.originalAuthor, revisedBy: d.revisedBy, versionNumber: d.versionNumber, content: d.content, parentVersionId: d.parentVersionId })) : []; |
|
|
if (!vers.length) { setTaskStageNote('No versions found to revise.'); return; } |
|
|
|
|
|
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 || !task?.id) return; |
|
|
try { |
|
|
const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(task.id)}/versions`), { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ originalAuthor: username, revisedBy: undefined, content: text }) |
|
|
}); |
|
|
const saved = await resp.json().catch(()=>({})); |
|
|
if (!resp.ok) { |
|
|
console.error('Failed to save version:', saved?.error || 'Unknown error', saved); |
|
|
throw new Error(saved?.error || 'Failed to save version'); |
|
|
} |
|
|
const newVersion: Version = { |
|
|
id: saved._id, |
|
|
taskId: saved.taskId, |
|
|
originalAuthor: saved.originalAuthor, |
|
|
revisedBy: saved.revisedBy, |
|
|
versionNumber: saved.versionNumber, |
|
|
content: saved.content, |
|
|
parentVersionId: saved.parentVersionId |
|
|
}; |
|
|
setVersions(prev => [...prev, newVersion]); |
|
|
|
|
|
try { |
|
|
const cacheKey = versionsCacheKey(task.id); |
|
|
const cached = JSON.parse(sessionStorage.getItem(cacheKey) || '[]'); |
|
|
const updated = [...cached, newVersion]; |
|
|
sessionStorage.setItem(cacheKey, JSON.stringify(updated)); |
|
|
} catch {} |
|
|
setPastedTranslation(''); |
|
|
setCurrentVersionId(newVersion.id); |
|
|
setStage('flow'); |
|
|
} catch (e) { |
|
|
console.error('Error saving draft translation:', e); |
|
|
} |
|
|
}; |
|
|
|
|
|
const selectManual = (id: string) => { |
|
|
setCurrentVersionId(id); |
|
|
setStage('editor'); |
|
|
}; |
|
|
|
|
|
const deleteTask = React.useCallback(async () => { |
|
|
try { |
|
|
if (!task?.id) return; |
|
|
const ok = window.confirm('Delete this Deep Revision task and all its versions? This cannot be undone.'); |
|
|
if (!ok) return; |
|
|
const headers: any = { 'x-user-name': username }; |
|
|
if (isAdmin) headers['x-user-role'] = 'admin'; |
|
|
const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(task.id)}`), { method: 'DELETE', headers }); |
|
|
if (!resp.ok) throw new Error('Delete failed'); |
|
|
setTasks(prev => prev.filter(t => t.id !== task.id)); |
|
|
setVersions([]); |
|
|
|
|
|
const remaining = tasks.filter(t => t.id !== (task?.id || '')); |
|
|
setSelectedTaskId(remaining.length ? remaining[0].id : ''); |
|
|
appliedInitialRouteRef.current = true; |
|
|
initialRouteRef.current = null; |
|
|
setStage('task'); |
|
|
} catch {} |
|
|
}, [isAdmin, task?.id, tasks, username]); |
|
|
|
|
|
const handleSaveRevision = React.useCallback(async (newContent: string) => { |
|
|
const parent = taskVersions.find(v => v.id === currentVersionId); |
|
|
if (!task?.id) return; |
|
|
try { |
|
|
const resp = await fetch(getApiBase(`/tasks/${encodeURIComponent(task.id)}/versions`), { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ |
|
|
originalAuthor: parent?.originalAuthor || username, |
|
|
revisedBy: username, |
|
|
content: newContent, |
|
|
parentVersionId: parent?.id, |
|
|
}) |
|
|
}); |
|
|
const saved = await resp.json().catch(()=>({})); |
|
|
if (!resp.ok) { |
|
|
console.error('Failed to save revision:', saved?.error || 'Unknown error', saved); |
|
|
throw new Error(saved?.error || 'Save failed'); |
|
|
} |
|
|
const v: Version = { |
|
|
id: saved._id, |
|
|
taskId: saved.taskId, |
|
|
originalAuthor: saved.originalAuthor, |
|
|
revisedBy: saved.revisedBy, |
|
|
versionNumber: saved.versionNumber, |
|
|
content: saved.content, |
|
|
parentVersionId: saved.parentVersionId, |
|
|
}; |
|
|
setVersions(prev => [...prev, v]); |
|
|
|
|
|
try { |
|
|
const cacheKey = versionsCacheKey(task.id); |
|
|
const cached = JSON.parse(sessionStorage.getItem(cacheKey) || '[]'); |
|
|
const updated = [...cached, v]; |
|
|
sessionStorage.setItem(cacheKey, JSON.stringify(updated)); |
|
|
} catch {} |
|
|
setCurrentVersionId(v.id); |
|
|
|
|
|
setStage('flow'); |
|
|
} catch (e) { |
|
|
console.error('Error saving revision:', e); |
|
|
|
|
|
} |
|
|
}, [task, taskVersions, currentVersionId, username, getApiBase, versionsCacheKey, getTutorialMode]); |
|
|
|
|
|
return ( |
|
|
<div className={isFullscreen ? 'fixed inset-0 z-50 bg-white overflow-auto' : 'relative'}> |
|
|
{isFullscreen && ( |
|
|
<div className="sticky top-0 z-50 flex justify-end p-3 bg-white/80 backdrop-blur"> |
|
|
<button onClick={() => setIsFullscreen(false)} className="px-3 py-1.5 text-sm rounded-2xl border border-gray-200 bg-white">Exit Full Screen</button> |
|
|
</div> |
|
|
)} |
|
|
{/* Annotation modal (Version Flow only) */} |
|
|
{annotationModalOpen && annotationPopover && ( |
|
|
<div className="fixed inset-0 z-[5000] flex items-center justify-center bg-black/40"> |
|
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6"> |
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">{editingAnnotation ? 'Edit annotation' : 'New annotation'}</h3> |
|
|
<div className="space-y-4"> |
|
|
<div> |
|
|
<label className="block text-sm text-gray-700 mb-1">Category <span className="text-red-500">*</span></label> |
|
|
<select |
|
|
value={modalCategory} |
|
|
onChange={(e)=>setModalCategory(e.target.value as AnnotationCategory)} |
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm" |
|
|
> |
|
|
{(['distortion','omission','register','unidiomatic','grammar','spelling','punctuation','addition','other'] as AnnotationCategory[]).map(k=>( |
|
|
<option key={k} value={k}>{k==='unidiomatic' ? 'Unidiomatic expression' : k==='grammar' ? 'Error of grammar, syntax' : k==='spelling' ? 'Error of spelling' : k==='punctuation' ? 'Error of punctuation' : k==='addition' ? 'Unjustified addition' : k==='omission' ? 'Unjustified omission' : k.charAt(0).toUpperCase()+k.slice(1)}</option> |
|
|
))} |
|
|
</select> |
|
|
</div> |
|
|
<div> |
|
|
<label className="block text-sm text-gray-700 mb-1">Comment (optional)</label> |
|
|
<textarea |
|
|
value={modalComment} |
|
|
onChange={(e)=>setModalComment(e.target.value)} |
|
|
rows={3} |
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm" |
|
|
placeholder="Enter the correction or context…" |
|
|
/> |
|
|
</div> |
|
|
</div> |
|
|
<div className="mt-6 flex items-center justify-between"> |
|
|
{editingAnnotation && ( |
|
|
<button |
|
|
onClick={()=>{ |
|
|
if (editingAnnotation) deleteAnnotationById(editingAnnotation.id); |
|
|
setAnnotationModalOpen(false); |
|
|
setAnnotationPopover(null); |
|
|
setEditingAnnotation(null); |
|
|
}} |
|
|
className="px-3 py-1.5 text-sm rounded-lg text-white bg-red-600 hover:bg-red-700" |
|
|
> |
|
|
Delete |
|
|
</button> |
|
|
)} |
|
|
<div className="ml-auto flex gap-2"> |
|
|
<button onClick={()=>{ setAnnotationModalOpen(false); setAnnotationPopover(null); setEditingAnnotation(null); }} className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 text-gray-700 bg-white hover:bg-gray-50">Cancel</button> |
|
|
<button |
|
|
onClick={()=>{ |
|
|
const versionId = annotationPopover.versionId; |
|
|
if (editingAnnotation) { |
|
|
const updated: Annotation = { ...editingAnnotation, category: modalCategory, comment: modalComment, updatedAt: Date.now() }; |
|
|
updateAnnotation(updated); |
|
|
} else { |
|
|
const range = annotationPopover.range; |
|
|
const a: Annotation = { |
|
|
id: `a_${Date.now()}_${Math.random().toString(36).slice(2,7)}`, |
|
|
versionId, |
|
|
start: range.start, |
|
|
end: range.end, |
|
|
category: modalCategory, |
|
|
comment: modalComment, |
|
|
createdAt: Date.now(), |
|
|
updatedAt: Date.now(), |
|
|
}; |
|
|
addAnnotation(a); |
|
|
} |
|
|
setAnnotationModalOpen(false); |
|
|
setAnnotationPopover(null); |
|
|
setEditingAnnotation(null); |
|
|
}} |
|
|
className="px-3 py-1.5 text-sm rounded-lg text-white bg-indigo-600 hover:bg-indigo-700" |
|
|
> |
|
|
Save |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
{/* Clean container (no purple gradient) */} |
|
|
<div className={isFullscreen ? 'relative mx-auto max-w-6xl p-6' : 'relative rounded-xl p-6 bg-transparent'}> |
|
|
{/* Stage 1: Task Creation / Selection */} |
|
|
{stage === 'task' && ( |
|
|
<div> |
|
|
<div className="mb-2" /> |
|
|
{/* Task selector (full width) */} |
|
|
<div className="mb-6"> |
|
|
<label className="block text-sm text-gray-700 mb-1">Task</label> |
|
|
<div className="relative inline-block w-full md:w-80"> |
|
|
<button type="button" onClick={()=>setTaskMenuOpen(o=>!o)} className="relative w-full text-left inline-flex items-center justify-between px-3 py-1.5 rounded-2xl border border-gray-200 ring-1 ring-inset ring-gray-200 backdrop-blur-md bg-white/30 text-sm"> |
|
|
<span className="truncate text-gray-900">{tasks.find(t=>t.id===selectedTaskId)?.title || 'Select a task'}</span> |
|
|
<span className="ml-2 text-gray-700">▾</span> |
|
|
</button> |
|
|
{taskMenuOpen && ( |
|
|
<div className="absolute z-20 mt-2 w-full"> |
|
|
<div className="relative rounded-2xl bg-white/10 backdrop-blur-md ring-1 ring-inset ring-gray-200 border border-gray-200 shadow-lg overflow-hidden"> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-50 [background:linear-gradient(to_bottom,rgba(255,255,255,0.3),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.28),rgba(255,255,255,0)_28%)]" /> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-45" /> |
|
|
<ul className="relative max-h-64 overflow-auto text-sm"> |
|
|
{tasks.map(t => ( |
|
|
<li key={t.id}> |
|
|
<button type="button" onClick={()=>{ |
|
|
userSelectedTaskIdRef.current = t.id; |
|
|
setSelectedTaskId(t.id); |
|
|
setTaskMenuOpen(false); |
|
|
// Persist user selection to localStorage in tutorial mode |
|
|
try { |
|
|
const isTutorial = localStorage.getItem('refinityMode') === 'tutorial'; |
|
|
if (isTutorial) { |
|
|
const weekNumber = parseInt(localStorage.getItem('tutorialWeekNumber') || '0'); |
|
|
if (weekNumber > 0) { |
|
|
localStorage.setItem(`tutorial_selected_task_week_${weekNumber}`, t.id); |
|
|
} |
|
|
} |
|
|
} catch {} |
|
|
}} className={`w-full text-left px-3 py-1.5 text-gray-900 hover:bg-white/30 ${selectedTaskId===t.id ? 'bg-indigo-600/20' : ''}`}>{t.title}</button> |
|
|
</li> |
|
|
))} |
|
|
{tasks.length===0 && ( |
|
|
<li className="px-3 py-2 text-sm text-gray-600">No tasks yet</li> |
|
|
)} |
|
|
</ul> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Unified input area: tabs + content in one container */} |
|
|
<div className="mb-6 rounded-2xl border border-gray-200 bg-white/60 backdrop-blur"> |
|
|
<div className="px-3 py-2 border-b border-gray-200"> |
|
|
<div className="inline-flex rounded-xl overflow-hidden ring-1 ring-gray-200"> |
|
|
<button onClick={()=>setInputTab('paste')} className={`px-3 py-1.5 text-sm ${inputTab==='paste' ? 'bg-white text-gray-900' : 'bg-gray-100 text-gray-600'}`}>Paste Text</button> |
|
|
<button onClick={()=>setInputTab('upload')} className={`px-3 py-1.5 text-sm ${inputTab==='upload' ? 'bg-white text-gray-900' : 'bg-gray-100 text-gray-600'}`}>Upload Document</button> |
|
|
</div> |
|
|
</div> |
|
|
<div className="p-4" style={{ minHeight: '210px' }}> |
|
|
{inputTab==='paste' ? ( |
|
|
<div> |
|
|
<textarea value={pastedTranslation} onChange={(e)=>setPastedTranslation(e.target.value)} rows={4} className="w-full px-4 py-3 border border-ui-border rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white" placeholder="Paste translation text here…" /> |
|
|
<div className="mt-2"> |
|
|
<button onClick={submitPastedTranslation} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-slate-800 hover:bg-slate-900 active:translate-y-0.5 transition-all duration-200">Use pasted text</button> |
|
|
</div> |
|
|
</div> |
|
|
) : ( |
|
|
<div> |
|
|
<input ref={fileInputRef} type="file" accept=".doc,.docx" onChange={(e)=>{ const f=e.target.files?.[0]; if(f) uploadDocx(f); if(fileInputRef.current) fileInputRef.current.value=''; }} className="hidden" /> |
|
|
<button type="button" onClick={()=>fileInputRef.current?.click()} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-slate-800 hover:bg-slate-900 active:translate-y-0.5 transition-all duration-200"> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" /> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" /> |
|
|
<span className="relative z-10">Choose file</span> |
|
|
</button> |
|
|
{uploading && <span className="ml-3 text-sm text-gray-600">Uploading…</span>} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div className="mt-8 flex gap-3 items-center flex-wrap"> |
|
|
<button |
|
|
onClick={()=>{ |
|
|
if (taskVersions.length > 0) { |
|
|
setStage('flow'); |
|
|
} else { |
|
|
// In tutorial mode, admin should have created the first version |
|
|
const isTut = getTutorialMode(); |
|
|
if (isTut) { |
|
|
setTaskStageNote('No versions found. Please contact admin to create the first version.'); |
|
|
} else { |
|
|
setTaskStageNote('No versions found to start.'); |
|
|
} |
|
|
} |
|
|
}} |
|
|
className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-violet-600/70 hover:bg-violet-700 active:translate-y-0.5 transition-all duration-200" |
|
|
> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" /> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" /> |
|
|
<span className="relative z-10">Start</span> |
|
|
</button> |
|
|
{(!getTutorialMode() || isAdmin) && ( |
|
|
<button onClick={()=>setShowAddTask(v=>!v)} className="relative inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-violet-700 ring-1 ring-inset ring-violet-300 bg-white/20 backdrop-blur-md hover:bg-violet-50 active:translate-y-0.5 transition-all duration-200"> |
|
|
<span className="relative z-10">New Task</span> |
|
|
</button> |
|
|
)} |
|
|
{(isAdmin || String(username).toLowerCase()===String(tasks.find(t=>t.id===selectedTaskId)?.createdBy||'').toLowerCase()) && task?.id && ( |
|
|
<div className="flex items-center gap-2"> |
|
|
<button onClick={openEditTask} className="relative inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-gray-800 ring-1 ring-inset ring-gray-300 bg-white/20 backdrop-blur-md hover:bg-gray-100 active:translate-y-0.5 transition-all duration-200"> |
|
|
<span className="relative z-10">Edit Task</span> |
|
|
</button> |
|
|
<button onClick={deleteTask} className="relative inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-red-700 ring-1 ring-inset ring-red-300 bg-white/20 backdrop-blur-md hover:bg-red-600 hover:text-white active:translate-y-0.5 transition-all duration-200"> |
|
|
<span className="relative z-10">Delete Task</span> |
|
|
</button> |
|
|
</div> |
|
|
)} |
|
|
{taskStageNote && <span className="text-sm text-gray-600 ml-2">{taskStageNote}</span>} |
|
|
</div> |
|
|
|
|
|
{showAddTask && ( |
|
|
<div className="mt-6 relative rounded-xl"> |
|
|
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-indigo-200/45 via-indigo-100/40 to-indigo-300/45" /> |
|
|
<div className="relative rounded-xl bg-white/10 backdrop-blur-md ring-1 ring-inset ring-white/30 shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)] p-4"> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-xl opacity-50 [background:linear-gradient(to_bottom,rgba(255,255,255,0.3),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.28),rgba(255,255,255,0)_28%)]" /> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-45" /> |
|
|
<div className="relative grid grid-cols-1 md:grid-cols-3 gap-4 items-start"> |
|
|
<div> |
|
|
<label className="block text-sm text-gray-700 mb-1">Title</label> |
|
|
<input value={newTaskTitle} onChange={(e)=>setNewTaskTitle(e.target.value)} className="w-full px-3 py-2 rounded-lg bg-white/50 backdrop-blur border border-ui-border text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" /> |
|
|
</div> |
|
|
<div className="md:col-span-2"> |
|
|
<label className="block text-sm text-gray-700 mb-1">Source Text</label> |
|
|
<textarea value={newTaskSource} onChange={(e)=>setNewTaskSource(e.target.value)} rows={4} className="w-full px-4 py-3 border border-ui-border rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white" placeholder="Paste source text here…" /> |
|
|
<div className="mt-2 flex items-center gap-3"> |
|
|
<input ref={addTaskFileRef} type="file" accept=".doc,.docx" onChange={(e)=>{ const f=e.target.files?.[0]; if(f) uploadSourceDoc(f); if(addTaskFileRef.current) addTaskFileRef.current.value=''; }} className="hidden" /> |
|
|
<button type="button" onClick={()=>addTaskFileRef.current?.click()} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-indigo-600/70 active:translate-y-0.5 transition-all duration-200"> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" /> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" /> |
|
|
<span className="relative z-10">Choose source file</span> |
|
|
</button> |
|
|
{addingSourceUploading && <span className="text-sm text-gray-600">Parsing…</span>} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div className="relative mt-4 flex gap-3"> |
|
|
<button onClick={saveNewTask} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-indigo-600/70 active:translate-y-0.5 transition-all duration-200">Save Task</button> |
|
|
<button onClick={()=>{ setShowAddTask(false); setNewTaskTitle(''); setNewTaskSource(''); }} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-black ring-1 ring-inset ring-white/50 backdrop-blur-md bg-white/30 active:translate-y-0.5 transition-all duration-200">Cancel</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
)} |
|
|
{/* Simple Edit Task Modal */} |
|
|
{editingTaskOpen && ( |
|
|
<div className="fixed inset-0 z-[5000] flex items-center justify-center bg-black/40"> |
|
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl p-6"> |
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Edit Task</h3> |
|
|
<div className="space-y-4"> |
|
|
<div> |
|
|
<label className="block text-sm text-gray-700 mb-1">Title</label> |
|
|
<input value={editTaskTitle} onChange={e=>setEditTaskTitle(e.target.value)} className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" /> |
|
|
</div> |
|
|
<div> |
|
|
<label className="block text-sm text-gray-700 mb-1">Source Text</label> |
|
|
<textarea value={editTaskSource} onChange={e=>setEditTaskSource(e.target.value)} className="w-full border border-gray-300 rounded-lg px-3 py-2 min-h-[200px] focus:outline-none focus:ring-2 focus:ring-indigo-500" /> |
|
|
</div> |
|
|
</div> |
|
|
<div className="mt-6 flex items-center justify-end gap-3"> |
|
|
<button onClick={()=>setEditingTaskOpen(false)} className="px-4 py-2 text-sm rounded-lg border border-gray-300 text-gray-700 bg-white hover:bg-gray-50">Cancel</button> |
|
|
<button onClick={saveTaskEdit} disabled={savingTaskEdit} className="px-4 py-2 text-sm rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60">{savingTaskEdit ? 'Saving...' : 'Save'}</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* Stage 2: Version Flow - Swiper Coverflow */} |
|
|
{stage === 'flow' && ( |
|
|
<div> |
|
|
<div className="flex items-center justify-between mb-4"> |
|
|
<div> |
|
|
<h3 className="text-xl font-semibold text-black">Version Flow — {task?.title}</h3> |
|
|
<div className="text-gray-700 text-sm">Swipe/click to browse. Center slide is active.</div> |
|
|
</div> |
|
|
<div className="flex gap-2"> |
|
|
<button onClick={()=>{ setIsFullscreen(false); setStage('task'); }} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-2xl text-black ring-1 ring-inset ring-white/50 backdrop-blur-md bg-white/30 active:translate-y-0.5 transition-all duration-200"> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" /> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" /> |
|
|
<span className="relative z-10">Back</span> |
|
|
</button> |
|
|
<button onClick={()=>setIsFullscreen(v=>!v)} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-2xl text-black ring-1 ring-inset ring-white/50 backdrop-blur-md bg-white/30 active:translate-y-0.5 transition-all duration-200"> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" /> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" /> |
|
|
<span className="relative z-10">{isFullscreen ? 'Windowed' : 'Full Screen'}</span> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
<div className={`relative min-h-0 ${isFullscreen ? 'pt-1 pb-2' : 'pt-2 pb-6'}`} style={{ maxHeight: (compareUIOpen || revDownloadOpen) ? 'none' : (isFullscreen ? 'calc(100vh - 80px)' : 'calc(100vh - 160px)') }}> |
|
|
<style>{` |
|
|
.mySwiper .swiper-pagination { z-index: 5; pointer-events: none; } |
|
|
.mySwiper .swiper-pagination-bullet { pointer-events: auto; } |
|
|
.mySwiper .swiper-slide { z-index: 1; } |
|
|
.mySwiper .swiper-slide-active { z-index: 1000; } |
|
|
.mySwiper .swiper-slide-next, .mySwiper .swiper-slide-prev { z-index: 500; } |
|
|
`}</style> |
|
|
<SwiperRoot |
|
|
modules={[EffectCoverflow, Navigation, Pagination, A11y]} |
|
|
effect="coverflow" |
|
|
onSlideChange={(s: SwiperInstance)=> setFlowIndex(s.activeIndex)} |
|
|
onAfterInit={(s: SwiperInstance)=> setFlowIndex(s.activeIndex)} |
|
|
onClickCapture={(e: any)=>{ /* keep default; no slide-level click */ }} |
|
|
navigation |
|
|
pagination={{ clickable: true }} |
|
|
centeredSlides |
|
|
// fixed height so cards fit viewport and never overflow page |
|
|
slidesPerView={'auto'} |
|
|
spaceBetween={32} |
|
|
preventClicks={false} |
|
|
preventClicksPropagation={false} |
|
|
noSwiping={true} |
|
|
noSwipingClass={'swiper-no-swiping'} |
|
|
breakpoints={{ |
|
|
640: { spaceBetween: 36 }, |
|
|
1024: { spaceBetween: 40 }, |
|
|
1440: { spaceBetween: 48 } |
|
|
}} |
|
|
observer |
|
|
observeParents |
|
|
allowTouchMove={false} |
|
|
coverflowEffect={{ rotate: 0, stretch: 0, depth: 80, modifier: 1, slideShadows: false }} |
|
|
className="mySwiper !px-4 overflow-visible pb-2" |
|
|
style={{ height: isFullscreen ? 'calc(100vh - 96px)' : 'min(76vh, calc(100vh - 220px))', minHeight: '340px' }} |
|
|
> |
|
|
{taskVersions.map((v, idx) => { |
|
|
const isCenter = idx === flowIndex; |
|
|
return ( |
|
|
<SwiperSlide key={v.id} style={{ width: 'clamp(320px, 48vw, 720px)', height: isFullscreen ? '72vh' : '62.4vh', zIndex: (idx === flowIndex ? 1000 : (Math.abs(idx - flowIndex) === 1 ? 500 : 1)) }}> |
|
|
<div |
|
|
className={`relative h-full rounded-2xl p-6 bg-white ring-1 ring-gray-200 shadow-xl text-base overflow-hidden flex flex-col ${isCenter ? 'z-[60]' : 'opacity-95 z-0'}`} |
|
|
role="button" |
|
|
onClick={(e)=>{ e.preventDefault(); setPreviewVersionId(v.id); setShowPreviewDiff(false); setStage('preview'); }} |
|
|
> |
|
|
<div className={`ref-card-overlay pointer-events-none absolute inset-0 rounded-2xl opacity-40 [background:linear-gradient(to_bottom,rgba(255,255,255,0.45),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]`} /> |
|
|
<div className={`ref-card-overlay pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-30`} /> |
|
|
<div className="text-gray-800 text-sm mb-2">Original: {v.originalAuthor}</div> |
|
|
<div className="text-gray-600 text-xs mb-3">Revised by: {v.revisedBy ? `${v.revisedBy} (v${v.versionNumber})` : `— (v${v.versionNumber})`}</div> |
|
|
<div |
|
|
ref={(el)=>{ textRefs.current[v.id]=el; }} |
|
|
className={`text-gray-900 whitespace-pre-wrap break-words leading-relaxed flex-1 overflow-hidden pr-1 relative`} |
|
|
style={{ maxHeight: 'calc(100% - 10.5rem)', paddingBottom: '0.25rem' }} |
|
|
> |
|
|
{v.content || ''} |
|
|
</div> |
|
|
{overflowMap[v.id] && ( |
|
|
<div className="pointer-events-none absolute text-gray-700" style={{ left: '1.5rem', bottom: '6rem', zIndex: 2000 }}>…</div> |
|
|
)} |
|
|
{isCenter && ( |
|
|
<div |
|
|
className="action-row absolute left-6 right-6 bottom-6 swiper-no-swiping" |
|
|
data-version-number={v.versionNumber} |
|
|
data-version-index={idx} |
|
|
data-version-id={v.id} |
|
|
data-owner={String(v.revisedBy || v.originalAuthor || '').toLowerCase() === String(username).toLowerCase() || !!isAdmin} |
|
|
onClickCapture={(e)=> { |
|
|
// Diagnostic: capture clicks within action row to ensure they are not swallowed by slide |
|
|
const t = e.target as HTMLElement; |
|
|
console.debug('[VF] action-row click(capture)', { |
|
|
idx, |
|
|
versionId: v.id, |
|
|
buttonData: t?.dataset || {}, |
|
|
targetTag: t?.tagName, |
|
|
targetClasses: t?.className |
|
|
}); |
|
|
}} |
|
|
onMouseEnter={()=>{ updateReviseOverlay(); }} |
|
|
onMouseMove={()=>{ updateReviseOverlay(); }} |
|
|
style={{ pointerEvents: 'auto', transform: 'translateZ(0)', zIndex: 3000 }} |
|
|
> |
|
|
{/* Invisible first child to bypass first-child quirks while matching pill styles (kept in place) */} |
|
|
<button |
|
|
type="button" |
|
|
aria-hidden="true" |
|
|
tabIndex={-1} |
|
|
className="inline-flex items-center justify-center gap-2 text-white text-sm font-medium rounded-2xl ring-1 ring-inset ring-white/50" |
|
|
style={{ opacity: 0, pointerEvents: 'none', position: 'absolute', left: 0, top: 0, padding: '0.5rem 0.75rem', backgroundColor: '#4f46e5', borderRadius: '1rem', border: '1px solid rgba(255,255,255,0.5)' }} |
|
|
/> |
|
|
<div className="flex justify-center gap-3 w-full"> |
|
|
<button |
|
|
type="button" |
|
|
data-btn="revise" |
|
|
ref={(el)=>{ reviseBtnRefs.current[v.id] = el; }} |
|
|
onClickCapture={(e)=>{ e.stopPropagation(); }} |
|
|
onPointerDown={(e)=>{ e.stopPropagation(); }} |
|
|
onPointerUp={(e)=>{ e.stopPropagation(); }} |
|
|
onClick={(e) => { |
|
|
e.preventDefault(); |
|
|
e.stopPropagation(); |
|
|
console.debug('[VF] Revise click', { idx, versionId: v.id, versionNumber: v.versionNumber }); |
|
|
selectManual(v.id); |
|
|
}} |
|
|
onMouseEnter={(e) => { |
|
|
// Diagnostic: inspect computed styles which could affect clickability |
|
|
try { |
|
|
const el = e.currentTarget as HTMLElement; |
|
|
const elCS = window.getComputedStyle(el); |
|
|
const parent = el.parentElement; |
|
|
const parentCS = parent ? window.getComputedStyle(parent) : null; |
|
|
const row = el.closest('.action-row') as HTMLElement | null; |
|
|
const rowCS = row ? window.getComputedStyle(row) : null; |
|
|
console.debug('[VF] Revise hover styles', { |
|
|
idx, |
|
|
versionId: v.id, |
|
|
el: { zIndex: elCS.zIndex, pointerEvents: elCS.pointerEvents, position: elCS.position }, |
|
|
parent: parentCS ? { zIndex: parentCS.zIndex, pointerEvents: parentCS.pointerEvents, position: parentCS.position } : null, |
|
|
row: rowCS ? { zIndex: rowCS.zIndex, pointerEvents: rowCS.pointerEvents, position: rowCS.position } : null |
|
|
}); |
|
|
} catch {} |
|
|
}} |
|
|
className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-violet-600/70 hover:bg-violet-700 active:translate-y-0.5 transition-all duration-200" |
|
|
style={{ position: 'relative', zIndex: 3100, pointerEvents: 'auto', cursor: 'pointer', touchAction: 'manipulation' }} |
|
|
> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" /> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" /> |
|
|
Revise |
|
|
</button> |
|
|
{(String(v.revisedBy || v.originalAuthor || '').toLowerCase() === String(username).toLowerCase() || isAdmin) && ( |
|
|
<button |
|
|
type="button" |
|
|
data-btn="edit" |
|
|
onClick={(e)=>{ e.preventDefault(); e.stopPropagation(); console.debug('[VF] Edit click', { idx, versionId: v.id }); setCurrentVersionId(v.id); setEditingVersionId(v.id); setStage('editor'); }} |
|
|
className="relative inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium rounded-2xl text-gray-800 ring-1 ring-inset ring-gray-300 bg-white/20 backdrop-blur-md hover:bg-gray-100 active:translate-y-0.5 transition-all duration-200" |
|
|
style={{ position: 'relative', zIndex: 3100, pointerEvents: 'auto', cursor: 'pointer', touchAction: 'manipulation' }} |
|
|
> |
|
|
Edit |
|
|
</button> |
|
|
)} |
|
|
<button |
|
|
type="button" |
|
|
data-btn="download" |
|
|
onClick={(e)=>{ |
|
|
e.preventDefault(); e.stopPropagation(); |
|
|
// Export single version content as plain .docx (same as Editor “Without Annotations”) |
|
|
(async()=>{ |
|
|
try { |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); |
|
|
const currentUser = username || 'User'; |
|
|
const filename = `${toSafeName(task?.title||'Task')}_${yyyymmdd_hhmm()}_${toSafeName(currentUser)}.docx`; |
|
|
const body = { current: v.content || '', filename }; |
|
|
const resp = await fetch(`${base}/api/refinity/export-plain`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); |
|
|
if (!resp.ok) throw new Error('Export failed'); |
|
|
const blob = await resp.blob(); |
|
|
const url = window.URL.createObjectURL(blob); |
|
|
const link = document.createElement('a'); |
|
|
link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); |
|
|
} catch {} |
|
|
})(); |
|
|
}} |
|
|
className="relative inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium rounded-2xl text-gray-800 ring-1 ring-inset ring-gray-300 bg-white/20 backdrop-blur-md hover:bg-gray-100 active:translate-y-0.5 transition-all duration-200" |
|
|
style={{ position: 'relative', zIndex: 3100, pointerEvents: 'auto', cursor: 'pointer', touchAction: 'manipulation' }} |
|
|
> |
|
|
Download |
|
|
</button> |
|
|
<button |
|
|
type="button" |
|
|
data-btn="compare" |
|
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); console.debug('[VF] Compare click', { idx, versionId: v.id }); setCompareUIOpen(true); if (!compareA) setCompareA(v.id); }} |
|
|
className="relative inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium rounded-2xl text-gray-800 ring-1 ring-inset ring-gray-300 bg-white/20 backdrop-blur-md hover:bg-gray-100 active:translate-y-0.5 transition-all duration-200" |
|
|
style={{ position: 'relative', zIndex: 3100, pointerEvents: 'auto', cursor: 'pointer', touchAction: 'manipulation' }} |
|
|
> |
|
|
Compare |
|
|
</button> |
|
|
{(isAdmin || String(v.revisedBy || v.originalAuthor || '').toLowerCase() === String(username).toLowerCase()) && ( |
|
|
<button |
|
|
type="button" |
|
|
data-btn="delete" |
|
|
onClick={(e)=>{ e.preventDefault(); e.stopPropagation(); console.debug('[VF] Delete click', { idx, versionId: v.id }); deleteVersion(v.id); }} |
|
|
className="relative inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium rounded-2xl text-red-700 ring-1 ring-inset ring-red-300 bg-white/20 backdrop-blur-md hover:bg-red-600 hover:text-white active:translate-y-0.5 transition-all duration-200" |
|
|
style={{ position: 'relative', zIndex: 3100, pointerEvents: 'auto', cursor: 'pointer', touchAction: 'manipulation' }} |
|
|
> |
|
|
Delete |
|
|
</button> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</SwiperSlide> |
|
|
); |
|
|
})} |
|
|
</SwiperRoot> |
|
|
{reviseOverlay && createPortal( |
|
|
<button |
|
|
aria-label="Revise overlay" |
|
|
style={{ |
|
|
position: 'fixed', |
|
|
left: `${reviseOverlay.left}px`, |
|
|
top: `${reviseOverlay.top}px`, |
|
|
width: `${reviseOverlay.width}px`, |
|
|
height: `${reviseOverlay.height}px`, |
|
|
background: 'transparent', |
|
|
border: 'none', |
|
|
padding: 0, |
|
|
margin: 0, |
|
|
cursor: 'pointer', |
|
|
zIndex: 2147483000 |
|
|
}} |
|
|
onClick={(e)=>{ e.preventDefault(); e.stopPropagation(); selectManual(reviseOverlay.vid); }} |
|
|
onPointerDown={(e)=>{ e.stopPropagation(); }} |
|
|
onPointerUp={(e)=>{ e.stopPropagation(); }} |
|
|
/>, |
|
|
document.body |
|
|
)} |
|
|
|
|
|
{/* External V2 Revise Button removed per user request */} |
|
|
|
|
|
{compareUIOpen && ( |
|
|
<div className="mt-5 p-3 rounded-2xl border border-gray-200 bg-white/60 backdrop-blur" style={{ position: 'relative' }}> |
|
|
<div className="flex flex-col md:flex-row gap-3 md:items-end"> |
|
|
<div className="flex-1"> |
|
|
<label className="block text-sm text-gray-700 mb-1">Version A (previous)</label> |
|
|
<select value={compareA} onChange={(e)=>setCompareA(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm"> |
|
|
<option value="">Select version…</option> |
|
|
{taskVersions.map(v => ( |
|
|
<option key={v.id} value={v.id}>{`v${v.versionNumber} — ${v.revisedBy || v.originalAuthor}`}</option> |
|
|
))} |
|
|
</select> |
|
|
</div> |
|
|
<div className="flex-1"> |
|
|
<label className="block text-sm text-gray-700 mb-1">Version B (current)</label> |
|
|
<select value={compareB} onChange={(e)=>setCompareB(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm"> |
|
|
<option value="">Select version…</option> |
|
|
{taskVersions.map(v => ( |
|
|
<option key={v.id} value={v.id}>{`v${v.versionNumber} — ${v.revisedBy || v.originalAuthor}`}</option> |
|
|
))} |
|
|
</select> |
|
|
</div> |
|
|
<div className="flex gap-2 overflow-visible items-start"> |
|
|
<button onClick={()=>{ const tmp = compareA; setCompareA(compareB); setCompareB(tmp); }} disabled={!compareA && !compareB} className="px-3 py-2 text-sm rounded-md border border-gray-300 bg-white">Swap</button> |
|
|
<button onClick={async()=>{ |
|
|
const a = taskVersions.find(v=>v.id===compareA); |
|
|
const b = taskVersions.find(v=>v.id===compareB); |
|
|
if (!a || !b || a.id===b.id) return; |
|
|
try { |
|
|
setCompareLoading(true); |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); |
|
|
const resp = await fetch(`${base}/api/refinity/diff`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prev: a.content || '', current: b.content || '' }) }); |
|
|
const data = await resp.json().catch(()=>({})); |
|
|
if (!resp.ok) throw new Error(data?.error || 'Diff failed'); |
|
|
setCompareDiffHtml(String(data?.html || '')); |
|
|
setCompareModalOpen(true); |
|
|
setLastDiffIds({ a: a.id, b: b.id }); |
|
|
} catch {} |
|
|
finally { setCompareLoading(false); } |
|
|
}} disabled={!compareA || !compareB || compareA===compareB || compareLoading} className="px-3 py-2 text-sm rounded-md border border-gray-300 bg-white disabled:opacity-50">{compareLoading ? 'Computing…' : 'Show Diff'}</button> |
|
|
<div className="relative"> |
|
|
<button ref={compareBtnRef} onClick={(e)=>{ e.preventDefault(); const r=(e.currentTarget as HTMLElement).getBoundingClientRect(); setCompareMenuPos({ left: r.left, top: r.bottom + 8 }); setCompareDownloadOpen(v=>!v); }} disabled={!compareA || !compareB || compareA===compareB} className="px-3 py-2 text-sm rounded-md border border-gray-300 bg-white">Download ▾</button> |
|
|
{compareDownloadOpen && compareMenuPos && createPortal( |
|
|
<div style={{ position: 'fixed', left: compareMenuPos.left, top: compareMenuPos.top, zIndex: 10000 }} className="w-52 rounded-md border border-gray-200 bg-white shadow-lg text-left"> |
|
|
<button onClick={async()=>{ |
|
|
setCompareDownloadOpen(false); |
|
|
const a = taskVersions.find(v=>v.id===compareA); |
|
|
const b = taskVersions.find(v=>v.id===compareB); |
|
|
if (!a || !b || a.id===b.id) return; |
|
|
try { |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); |
|
|
const older = (a.versionNumber <= b.versionNumber) ? a : b; |
|
|
const newer = (a.versionNumber <= b.versionNumber) ? b : a; |
|
|
const latestReviser = (newer.revisedBy || newer.originalAuthor || username || 'User'); |
|
|
const A = older.versionNumber, B = newer.versionNumber; |
|
|
const filename = `${toSafeName(task?.title||'Task')}_Compare-v${A}-v${B}_Inline_${yyyymmdd_hhmm()}_${toSafeName(latestReviser)}.docx`; |
|
|
const body = { prev: older.content||'', current: newer.content||'', filename, authorName: latestReviser }; |
|
|
let resp = await fetch(`${base}/api/refinity/track-changes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); |
|
|
if (!resp.ok) { |
|
|
// Fallback to plain export of current text |
|
|
resp = await fetch(`${base}/api/refinity/export-plain`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current: b.content||'', filename }) }); |
|
|
} |
|
|
if (!resp.ok) throw new Error('Export failed'); |
|
|
const blob = await resp.blob(); |
|
|
const url = window.URL.createObjectURL(blob); |
|
|
const link = document.createElement('a'); |
|
|
link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); |
|
|
} catch {} |
|
|
}} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Inline Changes</button> |
|
|
<button onClick={async()=>{ |
|
|
setCompareDownloadOpen(false); |
|
|
const a = taskVersions.find(v=>v.id===compareA); |
|
|
const b = taskVersions.find(v=>v.id===compareB); |
|
|
if (!a || !b || a.id===b.id) return; |
|
|
try { |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); |
|
|
const older = (a.versionNumber <= b.versionNumber) ? a : b; |
|
|
const newer = (a.versionNumber <= b.versionNumber) ? b : a; |
|
|
const latestReviser = (newer.revisedBy || newer.originalAuthor || username || 'User'); |
|
|
const A = older.versionNumber, B = newer.versionNumber; |
|
|
const filename = `${toSafeName(task?.title||'Task')}_Compare-v${A}-v${B}_Comments_${yyyymmdd_hhmm()}_${toSafeName(latestReviser)}.docx`; |
|
|
const body = { prev: older.content||'', current: newer.content||'', filename, authorName: latestReviser, includeComments: false }; |
|
|
let resp = await fetch(`${base}/api/refinity/track-changes-ooxml`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); |
|
|
if (!resp.ok) { |
|
|
// Fallback to inline; then to plain as last resort |
|
|
resp = await fetch(`${base}/api/refinity/track-changes-ooxml`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...body, includeComments: false }) }); |
|
|
if (!resp.ok) { |
|
|
resp = await fetch(`${base}/api/refinity/export-plain`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current: b.content||'', filename }) }); |
|
|
} |
|
|
} |
|
|
if (!resp.ok) throw new Error('Export failed'); |
|
|
const blob = await resp.blob(); |
|
|
const url = window.URL.createObjectURL(blob); |
|
|
const link = document.createElement('a'); |
|
|
link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); |
|
|
} catch {} |
|
|
}} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Tracked Changes</button> |
|
|
<button onClick={async()=>{ |
|
|
setCompareDownloadOpen(false); |
|
|
const a = taskVersions.find(v=>v.id===compareA); |
|
|
const b = taskVersions.find(v=>v.id===compareB); |
|
|
if (!a || !b || a.id===b.id) return; |
|
|
try { |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/,''); |
|
|
const older = (a.versionNumber <= b.versionNumber) ? a : b; |
|
|
const newer = (a.versionNumber <= b.versionNumber) ? b : a; |
|
|
const latestReviser = (newer.revisedBy || newer.originalAuthor || username || 'User'); |
|
|
const A = older.versionNumber, B = newer.versionNumber; |
|
|
const filename = `${toSafeName(task?.title||'Task')}_Compare-v${A}-v${B}_CommentsSidebar_${yyyymmdd_hhmm()}_${toSafeName(latestReviser)}.docx`; |
|
|
const body: any = { |
|
|
prev: older.content || '', |
|
|
current: newer.content || '', |
|
|
filename, |
|
|
authorName: latestReviser, |
|
|
annotationVersionId: older.id, |
|
|
}; |
|
|
let resp = await fetch(getApiBase('/compare-comments-with-corrections'), { |
|
|
method: 'POST', |
|
|
headers: (() => { |
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}'); |
|
|
const headers: any = { 'Content-Type': 'application/json' }; |
|
|
if (user.name) headers['x-user-name'] = user.name; |
|
|
if (user.email) headers['x-user-email'] = user.email; |
|
|
if (user.role) { |
|
|
headers['x-user-role'] = user.role; |
|
|
headers['user-role'] = user.role; |
|
|
} |
|
|
return headers; |
|
|
})(), |
|
|
body: JSON.stringify(body), |
|
|
}); |
|
|
if (!resp.ok) { |
|
|
// Fallback to plain export of older text |
|
|
resp = await fetch(getApiBase('/export-plain'), { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ current: older.content || '', filename }), |
|
|
}); |
|
|
} |
|
|
if (!resp.ok) throw new Error('Export failed'); |
|
|
const blob = await resp.blob(); |
|
|
const url = window.URL.createObjectURL(blob); |
|
|
const link = document.createElement('a'); |
|
|
link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); |
|
|
} catch {} |
|
|
}} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Sidebar Comments</button> |
|
|
</div>, document.body |
|
|
)} |
|
|
</div> |
|
|
<button onClick={()=>{ setCompareUIOpen(false); setCompareA(''); setCompareB(''); }} className="px-3 py-2 text-sm rounded-md border border-gray-300 bg-white">Close</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
{compareUIOpen && (<div aria-hidden="true" className="h-10" />)} |
|
|
<style dangerouslySetInnerHTML={{ __html: ` |
|
|
.mySwiper .swiper-wrapper { align-items: center; } |
|
|
.mySwiper .swiper-button-prev, .mySwiper .swiper-button-next { top: 50% !important; transform: translateY(-50%); } |
|
|
.mySwiper .swiper-button-prev:after, .mySwiper .swiper-button-next:after { font-size: 18px; color: #94a3b8; } |
|
|
.mySwiper .swiper-pagination { bottom: 6px; } |
|
|
/* Chrome-only: avoid half-line clipping at the bottom of slides */ |
|
|
.is-chrome .mySwiper .swiper-slide { padding-bottom: 6px; } |
|
|
.is-chrome .mySwiper .swiper-slide .whitespace-pre-wrap { padding-bottom: 6px; } |
|
|
/* Ensure buttons inside slides are clickable */ |
|
|
.mySwiper .swiper-slide button, .mySwiper .swiper-slide .action-row > * { |
|
|
pointer-events: auto !important; |
|
|
position: relative; |
|
|
z-index: 9999 !important; |
|
|
transform: translateZ(0) !important; |
|
|
-webkit-transform: translateZ(0) !important; |
|
|
} |
|
|
.mySwiper .swiper-slide button:hover, .mySwiper .swiper-slide .action-row > *:hover { transform: translateY(-0.5px) translateZ(0) !important; } |
|
|
/* Force button clickability on all slides */ |
|
|
.mySwiper .swiper-slide-active button, |
|
|
.mySwiper .swiper-slide button, |
|
|
.mySwiper .swiper-slide .action-row > * { |
|
|
pointer-events: auto !important; |
|
|
z-index: 9999 !important; |
|
|
} |
|
|
/* Ensure first child in action row is clickable */ |
|
|
.mySwiper .swiper-slide .action-row > *:first-child { |
|
|
pointer-events: auto !important; |
|
|
z-index: 99999 !important; |
|
|
position: relative !important; |
|
|
transform: translateZ(0) !important; |
|
|
-webkit-transform: translateZ(0) !important; |
|
|
} |
|
|
` }} /> |
|
|
</div> |
|
|
{compareModalOpen && ( |
|
|
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" role="dialog" aria-modal="true" key="compare-modal"> |
|
|
<div className="relative max-w-5xl w-full max-h-[85vh] rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200 p-0 overflow-hidden"> |
|
|
<div className="flex items-center justify-between px-6 pt-5 pb-3 border-b sticky top-0 z-10 bg-white"> |
|
|
<div className="text-gray-800 font-medium">Diff</div> |
|
|
<div className="flex items-center gap-2"> |
|
|
<button onClick={async()=>{ |
|
|
try { |
|
|
const a = taskVersions.find(v=>v.id===lastDiffIds.a || v.id===compareA); |
|
|
const b = taskVersions.find(v=>v.id===lastDiffIds.b || v.id===compareB); |
|
|
if (!a || !b || a.id===b.id) return; |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); |
|
|
const older = (a.versionNumber <= b.versionNumber) ? a : b; |
|
|
const newer = (a.versionNumber <= b.versionNumber) ? b : a; |
|
|
const latestReviser = (newer?.revisedBy || newer?.originalAuthor || username || 'User'); |
|
|
const A = older.versionNumber, B = newer.versionNumber; |
|
|
const filename = `${toSafeName(task?.title||'Task')}_Compare-v${A}-v${B}_Inline_${yyyymmdd_hhmm()}_${toSafeName(latestReviser)}.docx`; |
|
|
const body = { prev: (older.content||''), current: (newer.content||''), filename, authorName: latestReviser }; |
|
|
const resp = await fetch(`${base}/api/refinity/track-changes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); |
|
|
if (!resp.ok) throw new Error('Export failed'); |
|
|
const blob = await resp.blob(); |
|
|
const url = window.URL.createObjectURL(blob); |
|
|
const link = document.createElement('a'); |
|
|
link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); |
|
|
} catch {} |
|
|
}} className="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white">Export (Inline Changes)</button> |
|
|
<button onClick={async()=>{ |
|
|
try { |
|
|
const a = taskVersions.find(v=>v.id===lastDiffIds.a || v.id===compareA); |
|
|
const b = taskVersions.find(v=>v.id===lastDiffIds.b || v.id===compareB); |
|
|
if (!a || !b || a.id===b.id) return; |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); |
|
|
const older = (a.versionNumber <= b.versionNumber) ? a : b; |
|
|
const newer = (a.versionNumber <= b.versionNumber) ? b : a; |
|
|
const latestReviser = (newer?.revisedBy || newer?.originalAuthor || username || 'User'); |
|
|
const A = older.versionNumber, B = newer.versionNumber; |
|
|
const filename = `${toSafeName(task?.title||'Task')}_Compare-v${A}-v${B}_Comments_${yyyymmdd_hhmm()}_${toSafeName(latestReviser)}.docx`; |
|
|
const body = { prev: (older.content||''), current: (newer.content||''), filename, authorName: latestReviser, includeComments: false }; |
|
|
const resp = await fetch(`${base}/api/refinity/track-changes-ooxml`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); |
|
|
if (!resp.ok) throw new Error('Export failed'); |
|
|
const blob = await resp.blob(); |
|
|
const url = window.URL.createObjectURL(blob); |
|
|
const link = document.createElement('a'); |
|
|
link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); |
|
|
} catch {} |
|
|
}} className="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white">Export (Tracked Changes)</button> |
|
|
<button onClick={async()=>{ |
|
|
try { |
|
|
const a = taskVersions.find(v=>v.id===lastDiffIds.a || v.id===compareA); |
|
|
const b = taskVersions.find(v=>v.id===lastDiffIds.b || v.id===compareB); |
|
|
if (!a || !b || a.id===b.id) return; |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/,''); |
|
|
const older = (a.versionNumber <= b.versionNumber) ? a : b; |
|
|
const newer = (a.versionNumber <= b.versionNumber) ? b : a; |
|
|
const latestReviser = (newer?.revisedBy || newer?.originalAuthor || username || 'User'); |
|
|
const A = older.versionNumber, B = newer.versionNumber; |
|
|
const filename = `${toSafeName(task?.title||'Task')}_Compare-v${A}-v${B}_SidebarComments_${yyyymmdd_hhmm()}_${toSafeName(latestReviser)}.docx`; |
|
|
const body: any = { |
|
|
prev: older.content || '', |
|
|
current: newer.content || '', |
|
|
filename, |
|
|
authorName: latestReviser, |
|
|
annotationVersionId: older.id, |
|
|
}; |
|
|
let resp = await fetch(getApiBase('/compare-comments-with-corrections'), { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify(body), |
|
|
}); |
|
|
if (!resp.ok) { |
|
|
// Fallback to plain export of older text |
|
|
resp = await fetch(getApiBase('/export-plain'), { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ current: older.content || '', filename }), |
|
|
}); |
|
|
} |
|
|
if (!resp.ok) throw new Error('Export failed'); |
|
|
const blob = await resp.blob(); |
|
|
const url = window.URL.createObjectURL(blob); |
|
|
const link = document.createElement('a'); |
|
|
link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); |
|
|
} catch {} |
|
|
}} className="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white">Export (Sidebar Comments)</button> |
|
|
<button onClick={()=>setCompareModalOpen(false)} className="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white">Close</button> |
|
|
</div> |
|
|
</div> |
|
|
<div className="px-6 pb-6 overflow-auto max-h-[70vh]"> |
|
|
<div className="prose prose-sm max-w-none text-gray-900 whitespace-pre-wrap break-words" dangerouslySetInnerHTML={{ __html: compareDiffHtml }} /> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* Stage: Preview full text */} |
|
|
{stage === 'preview' && ( |
|
|
<> |
|
|
{!previewVersion && ( |
|
|
<div className="rounded-xl ring-1 ring-gray-200 shadow-sm overflow-hidden bg-white/60 min-h-[280px] animate-pulse" /> |
|
|
)} |
|
|
{previewVersion && ( |
|
|
<PreviewPane |
|
|
version={previewVersion || (flowIndex >= 0 ? (taskVersions[flowIndex] || null) : null)} |
|
|
onBack={()=>setStage('flow')} |
|
|
onEdit={()=>{ if (previewVersionId) { setCurrentVersionId(previewVersionId); setStage('editor'); } }} |
|
|
compareInitially={showPreviewDiff} |
|
|
/> |
|
|
)} |
|
|
</> |
|
|
)} |
|
|
|
|
|
{/* Stage 3: Editor */} |
|
|
{stage === 'editor' && ( |
|
|
<> |
|
|
{(!currentVersion || !task?.sourceText) && ( |
|
|
<div className="rounded-xl ring-1 ring-gray-200 shadow-sm overflow-hidden bg-white/60 min-h-[520px] animate-pulse" /> |
|
|
)} |
|
|
{currentVersion && task?.sourceText && ( |
|
|
<EditorPane |
|
|
source={task?.sourceText || ''} |
|
|
initialTranslation={currentVersion?.content || ''} |
|
|
getApiBase={getApiBase} |
|
|
onBack={()=>setStage('flow')} |
|
|
onSave={handleSaveRevision} |
|
|
onSaveEdit={editingVersionId ? ((text)=>saveEditedVersion(editingVersionId, text)) : undefined} |
|
|
taskTitle={task?.title || 'Task'} |
|
|
username={username} |
|
|
nextVersionNumber={((versions.filter(v=>v.taskId===(task?.id||'')).slice(-1)[0]?.versionNumber) || 0) + 1} |
|
|
isFullscreen={isFullscreen} |
|
|
onToggleFullscreen={()=>setIsFullscreen(v=>!v)} |
|
|
versionId={currentVersionId || ''} |
|
|
/> |
|
|
)} |
|
|
</> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack: ()=>void; onSave: (text: string)=>void; onSaveEdit?: (text: string)=>void; taskTitle: string; username: string; nextVersionNumber: number; isFullscreen: boolean; onToggleFullscreen: ()=>void; versionId: string; getApiBase: (endpoint: string) => string }>=({ source, initialTranslation, onBack, onSave, onSaveEdit, taskTitle, username, nextVersionNumber, isFullscreen, onToggleFullscreen, versionId, getApiBase })=>{ |
|
|
const [text, setText] = React.useState<string>(initialTranslation); |
|
|
const [saving, setSaving] = React.useState(false); |
|
|
|
|
|
React.useEffect(() => { |
|
|
setText(initialTranslation || ''); |
|
|
}, [versionId, initialTranslation]); |
|
|
const [diffHtml, setDiffHtml] = React.useState<string>(''); |
|
|
const [showDiff, setShowDiff] = React.useState<boolean>(false); |
|
|
const [revDownloadOpen, setRevDownloadOpen] = React.useState<boolean>(false); |
|
|
const [revMenuPos, setRevMenuPos] = React.useState<{ left: number; top: number } | null>(null); |
|
|
const [diffDownloadOpen, setDiffDownloadOpen] = React.useState<boolean>(false); |
|
|
const [diffMenuPos, setDiffMenuPos] = React.useState<{ left: number; top: number } | null>(null); |
|
|
const revBtnRef = React.useRef<HTMLButtonElement | null>(null); |
|
|
const diffBtnRef = React.useRef<HTMLButtonElement | null>(null); |
|
|
const sourceRef = React.useRef<HTMLDivElement>(null); |
|
|
const [textareaHeight, setTextareaHeight] = React.useState('420px'); |
|
|
const [commentsOpen, setCommentsOpen] = React.useState(false); |
|
|
const [newComment, setNewComment] = React.useState(''); |
|
|
const [comments, setComments] = React.useState<Array<{ id: string; text: string; ts: number }>>([]); |
|
|
const taRef = React.useRef<HTMLTextAreaElement | null>(null); |
|
|
const overlayRef = React.useRef<HTMLDivElement | null>(null); |
|
|
const wrapperRef = React.useRef<HTMLDivElement | null>(null); |
|
|
const annotatedRef = React.useRef<HTMLDivElement | null>(null); |
|
|
|
|
|
const [showAnnotations, setShowAnnotations] = React.useState<boolean>(true); |
|
|
const [toastMsg, setToastMsg] = React.useState<string>(''); |
|
|
const [dirty, setDirty] = React.useState<boolean>(false); |
|
|
React.useEffect(() => { setDirty(false); }, [versionId, initialTranslation]); |
|
|
React.useEffect(() => { |
|
|
const handler = (e: BeforeUnloadEvent) => { |
|
|
if (dirty) { e.preventDefault(); e.returnValue = ''; } |
|
|
}; |
|
|
window.addEventListener('beforeunload', handler); |
|
|
return () => window.removeEventListener('beforeunload', handler); |
|
|
}, [dirty]); |
|
|
const showToast = (msg: string) => { setToastMsg(msg); setTimeout(()=>setToastMsg(''), 1800); }; |
|
|
|
|
|
const ADMIN_ANNO_VIEW_CLEAN = '__clean__'; |
|
|
const ADMIN_ANNO_VIEW_EDIT = '__edit__'; |
|
|
const ADMIN_ANNO_VIEW_LEGACY = '__legacy__'; |
|
|
const [adminAnnoView, setAdminAnnoView] = React.useState<string>(ADMIN_ANNO_VIEW_CLEAN); |
|
|
const [annoCreators, setAnnoCreators] = React.useState<string[]>([]); |
|
|
const isTutorialMode = (() => { try { return localStorage.getItem('refinityMode') === 'tutorial'; } catch { return false; } })(); |
|
|
const currentUserLower = String(username || '').toLowerCase(); |
|
|
const isAdminUser = (() => { |
|
|
try { return String(JSON.parse(localStorage.getItem('user') || '{}')?.role || '').toLowerCase() === 'admin'; } catch { return false; } |
|
|
})(); |
|
|
const adminCanEditAnnotations = !(isTutorialMode && isAdminUser && adminAnnoView !== ADMIN_ANNO_VIEW_EDIT); |
|
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
if (isTutorialMode && isAdminUser) setAdminAnnoView(ADMIN_ANNO_VIEW_CLEAN); |
|
|
|
|
|
}, [versionId]); |
|
|
const ANNO_STORE_KEY = 'refinity_annotations_v1'; |
|
|
type Ann = Annotation; |
|
|
const loadAnnotations = React.useCallback((): Ann[] => { |
|
|
try { const raw = localStorage.getItem(ANNO_STORE_KEY); const arr = raw ? JSON.parse(raw) : []; return Array.isArray(arr) ? arr : []; } catch { return []; } |
|
|
}, []); |
|
|
const saveAnnotations = React.useCallback((list: Ann[]) => { try { localStorage.setItem(ANNO_STORE_KEY, JSON.stringify(list)); } catch {} }, []); |
|
|
const [annotations, setAnnotations] = React.useState<Ann[]>(() => loadAnnotations()); |
|
|
React.useEffect(() => { saveAnnotations(annotations); }, [annotations, saveAnnotations]); |
|
|
const versionAnnotations = React.useMemo(() => { |
|
|
const base = annotations.filter(a => a.versionId === versionId); |
|
|
if (isTutorialMode) { |
|
|
if (isAdminUser) { |
|
|
if (adminAnnoView === ADMIN_ANNO_VIEW_CLEAN) return []; |
|
|
if (adminAnnoView === ADMIN_ANNO_VIEW_EDIT) return base.filter(a => String(a.createdBy || '').toLowerCase() === currentUserLower); |
|
|
if (adminAnnoView === ADMIN_ANNO_VIEW_LEGACY) return base.filter(a => !a.createdBy); |
|
|
return base.filter(a => String(a.createdBy || '').toLowerCase() === String(adminAnnoView || '').toLowerCase()); |
|
|
} |
|
|
|
|
|
return base.filter(a => !a.createdBy || String(a.createdBy || '').toLowerCase() === currentUserLower); |
|
|
} |
|
|
return base; |
|
|
}, [annotations, versionId, isTutorialMode, isAdminUser, adminAnnoView, currentUserLower]); |
|
|
const addAnnotation = (a: Ann) => setAnnotations(prev => [...prev, a]); |
|
|
const updateAnnotation = (a: Ann) => setAnnotations(prev => prev.map(x => x.id === a.id ? a : x)); |
|
|
const deleteAnnotationById = (id: string) => setAnnotations(prev => prev.filter(a => a.id !== id)); |
|
|
const CATEGORY_CLASS: Record<AnnotationCategory, string> = { |
|
|
distortion: 'bg-rose-100 ring-rose-200', |
|
|
omission: 'bg-amber-100 ring-amber-200', |
|
|
register: 'bg-indigo-100 ring-indigo-200', |
|
|
unidiomatic: 'bg-purple-100 ring-purple-200', |
|
|
grammar: 'bg-blue-100 ring-blue-200', |
|
|
spelling: 'bg-green-100 ring-green-200', |
|
|
punctuation: 'bg-teal-100 ring-teal-200', |
|
|
addition: 'bg-pink-100 ring-pink-200', |
|
|
other: 'bg-gray-100 ring-gray-200', |
|
|
}; |
|
|
const [modalOpen, setModalOpen] = React.useState(false); |
|
|
const [editingAnn, setEditingAnn] = React.useState<Ann | null>(null); |
|
|
const [modalCategory, setModalCategory] = React.useState<AnnotationCategory>('distortion'); |
|
|
const [modalComment, setModalComment] = React.useState(''); |
|
|
const [modalCorrection, setModalCorrection] = React.useState(''); |
|
|
const [modalIsDeletion, setModalIsDeletion] = React.useState(false); |
|
|
const [modalSelectedText, setModalSelectedText] = React.useState(''); |
|
|
const [modalSourceSnippet, setModalSourceSnippet] = React.useState(''); |
|
|
const [showSourcePane, setShowSourcePane] = React.useState(false); |
|
|
const [popover, setPopover] = React.useState<{ left: number; top: number; start: number; end: number } | null>(null); |
|
|
|
|
|
function escapeHtml(s: string): string { |
|
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); |
|
|
} |
|
|
|
|
|
React.useEffect(() => { |
|
|
(async () => { |
|
|
if (!versionId) return; |
|
|
try { |
|
|
const resp = await fetch(getApiBase(`/annotations?versionId=${encodeURIComponent(versionId)}`)); |
|
|
const rows = await resp.json().catch(()=>[]); |
|
|
if (Array.isArray(rows)) { |
|
|
|
|
|
const isTutorial = localStorage.getItem('refinityMode') === 'tutorial'; |
|
|
const user = localStorage.getItem('user'); |
|
|
const currentUsername = user ? (JSON.parse(user)?.name || JSON.parse(user)?.email || '').toLowerCase() : ''; |
|
|
const isAdmin = user ? (JSON.parse(user)?.role || '').toLowerCase() === 'admin' : false; |
|
|
|
|
|
let filteredRows = rows; |
|
|
if (isTutorial && !isAdmin && currentUsername) { |
|
|
|
|
|
filteredRows = rows.filter((r: any) => !r.createdBy || r.createdBy.toLowerCase() === currentUsername); |
|
|
} |
|
|
|
|
|
|
|
|
if (isTutorial && isAdmin) { |
|
|
const creators = Array.from(new Set( |
|
|
rows |
|
|
.map((r: any) => (r?.createdBy ? String(r.createdBy).toLowerCase() : '')) |
|
|
.filter(Boolean) |
|
|
)).sort(); |
|
|
setAnnoCreators(creators); |
|
|
} else { |
|
|
setAnnoCreators([]); |
|
|
} |
|
|
|
|
|
setAnnotations(prev => { |
|
|
const others = prev.filter(a => a.versionId !== versionId); |
|
|
const loaded = filteredRows.map((r:any)=>{ |
|
|
const existing = prev.find(a => a.id === r._id); |
|
|
return { |
|
|
id: r._id, |
|
|
versionId: r.versionId, |
|
|
start: r.start, |
|
|
end: r.end, |
|
|
category: r.category, |
|
|
comment: r.comment, |
|
|
correction: (r.correction !== undefined ? r.correction : existing?.correction), |
|
|
createdBy: (r.createdBy !== undefined ? r.createdBy : existing?.createdBy), |
|
|
createdAt: r.createdAt, |
|
|
updatedAt: r.updatedAt, |
|
|
} as Ann; |
|
|
}); |
|
|
return [...others, ...loaded]; |
|
|
}); |
|
|
} |
|
|
} catch {} |
|
|
})(); |
|
|
}, [versionId, getApiBase]); |
|
|
|
|
|
|
|
|
const persistCreate = React.useCallback(async (a: Ann): Promise<Ann> => { |
|
|
try { |
|
|
const body = { versionId: a.versionId, start: a.start, end: a.end, category: a.category, comment: a.comment, correction: a.correction }; |
|
|
const headers: any = { 'Content-Type': 'application/json' }; |
|
|
|
|
|
if (username && username !== 'Anonymous') { |
|
|
headers['x-user-name'] = username; |
|
|
} |
|
|
const resp = await fetch(getApiBase('/annotations'), { method: 'POST', headers, body: JSON.stringify(body) }); |
|
|
const row = await resp.json().catch(()=>({})); |
|
|
if (resp.ok && row && row._id) return { ...a, id: row._id }; |
|
|
} catch {} |
|
|
return a; |
|
|
}, [getApiBase, username]); |
|
|
const persistUpdate = React.useCallback(async (a: Ann) => { |
|
|
try { |
|
|
const body = { start: a.start, end: a.end, category: a.category, comment: a.comment, correction: a.correction }; |
|
|
await fetch(getApiBase(`/annotations/${encodeURIComponent(a.id)}`), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); |
|
|
} catch {} |
|
|
}, [getApiBase]); |
|
|
const persistDelete = React.useCallback(async (id: string) => { |
|
|
try { |
|
|
await fetch(getApiBase(`/annotations/${encodeURIComponent(id)}`), { method: 'DELETE' }); |
|
|
} catch {} |
|
|
}, [getApiBase]); |
|
|
function renderAnnotatedHtml(textValue: string): string { |
|
|
|
|
|
|
|
|
if (!showAnnotations || !versionAnnotations.length) { |
|
|
return `<span data-start="0" data-end="${textValue.length}">${escapeHtml(textValue)}</span>`; |
|
|
} |
|
|
|
|
|
const list = versionAnnotations.slice().sort((a,b)=>a.start-b.start); |
|
|
let html = ''; let pos = 0; |
|
|
for (const a of list) { |
|
|
const start = Math.max(0, Math.min(a.start, textValue.length)); |
|
|
const end = Math.max(start, Math.min(a.end, textValue.length)); |
|
|
if (start > pos) { |
|
|
const plain = textValue.slice(pos, start); |
|
|
if (plain) { |
|
|
html += `<span data-start="${pos}" data-end="${start}">${escapeHtml(plain)}</span>`; |
|
|
} |
|
|
} |
|
|
const cls = CATEGORY_CLASS[a.category] || 'bg-gray-100'; |
|
|
const spanText = escapeHtml(textValue.slice(start, end)) || ' '; |
|
|
html += `<span data-anno-id="${a.id}" data-start="${start}" data-end="${end}" class="inline rounded-[4px] ${cls}" title="${a.category}${a.comment ? ': '+escapeHtml(a.comment) : ''}">${spanText}</span>`; |
|
|
pos = end; |
|
|
} |
|
|
if (pos < textValue.length) { |
|
|
const tail = textValue.slice(pos); |
|
|
if (tail) { |
|
|
html += `<span data-start="${pos}" data-end="${textValue.length}">${escapeHtml(tail)}</span>`; |
|
|
} |
|
|
} |
|
|
return html; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function computeSourceSnippetForOffset(sourceText: string, translationText: string, offset: number): string { |
|
|
if (!sourceText || !translationText) return ''; |
|
|
|
|
|
|
|
|
const findParagraph = (text: string, pos: number): { start: number; end: number } => { |
|
|
const len = text.length; |
|
|
let paraStart = 0; |
|
|
let paraEnd = len; |
|
|
|
|
|
|
|
|
|
|
|
for (let i = Math.min(pos, len - 1); i >= 0; i--) { |
|
|
|
|
|
if (i > 0 && text[i - 1] === '\n' && text[i] === '\n') { |
|
|
paraStart = i + 1; |
|
|
break; |
|
|
} |
|
|
|
|
|
if (i > 0 && text[i - 1] === '\n') { |
|
|
|
|
|
let j = i; |
|
|
while (j < len && (text[j] === ' ' || text[j] === '\t')) j++; |
|
|
if (j < len) { |
|
|
const char = text[j]; |
|
|
|
|
|
if ((char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || (char >= '\u4e00' && char <= '\u9fff')) { |
|
|
paraStart = i; |
|
|
break; |
|
|
} |
|
|
} |
|
|
} |
|
|
if (i === 0) { |
|
|
paraStart = 0; |
|
|
break; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
for (let i = Math.max(pos, paraStart); i < len; i++) { |
|
|
|
|
|
if (i < len - 1 && text[i] === '\n' && text[i + 1] === '\n') { |
|
|
paraEnd = i; |
|
|
break; |
|
|
} |
|
|
|
|
|
if (i < len - 1 && text[i] === '\n' && text[i + 1] !== '\n') { |
|
|
|
|
|
let j = i + 1; |
|
|
while (j < len && (text[j] === ' ' || text[j] === '\t')) j++; |
|
|
if (j < len) { |
|
|
const nextChar = text[j]; |
|
|
|
|
|
if ((nextChar >= 'A' && nextChar <= 'Z') || (nextChar >= '0' && nextChar <= '9') || (nextChar >= '\u4e00' && nextChar <= '\u9fff')) { |
|
|
|
|
|
let hasContent = false; |
|
|
for (let k = paraStart; k < i; k++) { |
|
|
if (text[k] !== ' ' && text[k] !== '\n' && text[k] !== '\t' && text[k] !== '\r') { |
|
|
hasContent = true; |
|
|
break; |
|
|
} |
|
|
} |
|
|
if (hasContent) { |
|
|
paraEnd = i; |
|
|
break; |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
if (i === len - 1) { |
|
|
paraEnd = len; |
|
|
break; |
|
|
} |
|
|
} |
|
|
|
|
|
return { start: paraStart, end: paraEnd }; |
|
|
}; |
|
|
|
|
|
|
|
|
const trPara = findParagraph(translationText, offset); |
|
|
|
|
|
|
|
|
|
|
|
const trRatio = trPara.start / Math.max(translationText.length, 1); |
|
|
const estimatedSrcPos = Math.floor(sourceText.length * trRatio); |
|
|
const srcPara = findParagraph(sourceText, estimatedSrcPos); |
|
|
|
|
|
|
|
|
const snippet = sourceText.slice(srcPara.start, srcPara.end).trim(); |
|
|
|
|
|
|
|
|
if (snippet.length > (trPara.end - trPara.start) * 3 && snippet.length > 500) { |
|
|
|
|
|
const sentences = snippet.split(/[.!?。!?]\s+/); |
|
|
if (sentences.length > 1) { |
|
|
const midIdx = Math.floor(sentences.length / 2); |
|
|
const startIdx = Math.max(0, midIdx - 1); |
|
|
const endIdx = Math.min(sentences.length, midIdx + 2); |
|
|
return sentences.slice(startIdx, endIdx).join('. ').trim(); |
|
|
} |
|
|
} |
|
|
|
|
|
return snippet || sourceText.slice(0, Math.min(500, sourceText.length)); |
|
|
} |
|
|
|
|
|
const adjustAnnotationsForEdit = React.useCallback((oldText: string, newText: string) => { |
|
|
if (oldText === newText) return; |
|
|
const oldLen = oldText.length, newLen = newText.length; |
|
|
let i = 0; |
|
|
while (i < oldLen && i < newLen && oldText[i] === newText[i]) i++; |
|
|
let a = oldLen - 1, b = newLen - 1; |
|
|
while (a >= i && b >= i && oldText[a] === newText[b]) { a--; b--; } |
|
|
const oldEditStart = i; |
|
|
const oldEditEnd = a + 1; |
|
|
const delta = (b + 1) - (a + 1); |
|
|
setAnnotations(prev => { |
|
|
const others = prev.filter(p => p.versionId !== versionId); |
|
|
const mine = prev.filter(p => p.versionId === versionId); |
|
|
const adjusted: Ann[] = []; |
|
|
mine.forEach(ann => { |
|
|
if (ann.end <= oldEditStart) { |
|
|
|
|
|
adjusted.push(ann); |
|
|
} else if (ann.start >= oldEditEnd) { |
|
|
|
|
|
adjusted.push({ ...ann, start: ann.start + delta, end: ann.end + delta }); |
|
|
} else { |
|
|
|
|
|
|
|
|
} |
|
|
}); |
|
|
return [...others, ...adjusted]; |
|
|
}); |
|
|
}, [versionId, setAnnotations]); |
|
|
|
|
|
|
|
|
|
|
|
function getOffsetsFromSelection(): { start: number; end: number; rect: DOMRect | null } | null { |
|
|
const container = annotatedRef.current; |
|
|
if (!container) return null; |
|
|
const sel = window.getSelection(); |
|
|
if (!sel || sel.rangeCount === 0) return null; |
|
|
const range = sel.getRangeAt(0); |
|
|
if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) return null; |
|
|
|
|
|
const resolveOffset = (node: Node, offset: number): number | null => { |
|
|
|
|
|
let el: HTMLElement | null = (node.nodeType === Node.ELEMENT_NODE |
|
|
? node as HTMLElement |
|
|
: node.parentElement as HTMLElement | null); |
|
|
|
|
|
while (el && el !== container && !el.hasAttribute('data-start')) { |
|
|
el = el.parentElement; |
|
|
} |
|
|
|
|
|
if (!el || !el.hasAttribute('data-start')) { |
|
|
|
|
|
const allSpans = container.querySelectorAll('[data-start]'); |
|
|
if (allSpans.length === 0) return null; |
|
|
|
|
|
el = allSpans[0] as HTMLElement; |
|
|
} |
|
|
|
|
|
const baseStart = Number(el.getAttribute('data-start') || '0') || 0; |
|
|
|
|
|
|
|
|
const r = document.createRange(); |
|
|
try { |
|
|
r.selectNodeContents(el); |
|
|
r.setEnd(node, offset); |
|
|
} catch (e) { |
|
|
|
|
|
return baseStart; |
|
|
} |
|
|
|
|
|
const localLen = r.toString().length; |
|
|
return baseStart + localLen; |
|
|
}; |
|
|
|
|
|
const start = resolveOffset(range.startContainer, range.startOffset); |
|
|
const end = resolveOffset(range.endContainer, range.endOffset); |
|
|
|
|
|
if (start == null || end == null) { |
|
|
|
|
|
try { |
|
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); |
|
|
let charCount = 0; |
|
|
let startOffset = -1; |
|
|
let endOffset = -1; |
|
|
|
|
|
while (walker.nextNode()) { |
|
|
const node = walker.currentNode as Text; |
|
|
const nodeText = node.nodeValue || ''; |
|
|
|
|
|
if (node === range.startContainer) { |
|
|
startOffset = charCount + range.startOffset; |
|
|
} |
|
|
if (node === range.endContainer) { |
|
|
endOffset = charCount + range.endOffset; |
|
|
break; |
|
|
} |
|
|
|
|
|
charCount += nodeText.length; |
|
|
} |
|
|
|
|
|
if (startOffset >= 0 && endOffset >= 0 && endOffset > startOffset) { |
|
|
const rect = range.getBoundingClientRect(); |
|
|
return { start: startOffset, end: endOffset, rect }; |
|
|
} |
|
|
} catch (e) { |
|
|
console.debug('[getOffsetsFromSelection] fallback failed:', e); |
|
|
} |
|
|
return null; |
|
|
} |
|
|
|
|
|
const rect = range.getBoundingClientRect(); |
|
|
return { start, end, rect }; |
|
|
} |
|
|
|
|
|
const updateSelectionPopover = React.useCallback(() => { |
|
|
const wrap = wrapperRef.current; |
|
|
const container = annotatedRef.current; |
|
|
if (!wrap || !container) { |
|
|
setPopover(null); |
|
|
return; |
|
|
} |
|
|
|
|
|
const sel = window.getSelection(); |
|
|
if (!sel || sel.rangeCount === 0) { |
|
|
setPopover(null); |
|
|
return; |
|
|
} |
|
|
|
|
|
const res = getOffsetsFromSelection(); |
|
|
if (!res) { |
|
|
setPopover(null); |
|
|
return; |
|
|
} |
|
|
|
|
|
const { start, end, rect } = res; |
|
|
if (!rect) { |
|
|
setPopover(null); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (end <= start) { |
|
|
setPopover(null); |
|
|
return; |
|
|
} |
|
|
|
|
|
const wrect = wrap.getBoundingClientRect(); |
|
|
const btnW = 120, btnH = 30, padding = 6; |
|
|
|
|
|
let left = rect.left - wrect.left + (rect.width - btnW) / 2; |
|
|
left = Math.max(padding, Math.min(left, wrect.width - btnW - padding)); |
|
|
|
|
|
let top = rect.top - wrect.top - btnH - padding; |
|
|
if (top < padding) { |
|
|
top = rect.bottom - wrect.top + padding; |
|
|
} |
|
|
|
|
|
setPopover({ left, top, start, end }); |
|
|
}, []); |
|
|
|
|
|
React.useEffect(() => { |
|
|
if (sourceRef.current) { |
|
|
const height = sourceRef.current.offsetHeight; |
|
|
setTextareaHeight(`${height}px`); |
|
|
} |
|
|
}, [source]); |
|
|
|
|
|
React.useEffect(() => { |
|
|
const measure = () => { |
|
|
if (sourceRef.current) { |
|
|
const h = sourceRef.current.offsetHeight; |
|
|
setTextareaHeight(`${h}px`); |
|
|
} |
|
|
}; |
|
|
|
|
|
requestAnimationFrame(() => requestAnimationFrame(measure)); |
|
|
}, [isFullscreen]); |
|
|
|
|
|
React.useEffect(() => { |
|
|
const onResize = () => { |
|
|
if (sourceRef.current) { |
|
|
const h = sourceRef.current.offsetHeight; |
|
|
setTextareaHeight(`${h}px`); |
|
|
} |
|
|
}; |
|
|
window.addEventListener('resize', onResize); |
|
|
return () => window.removeEventListener('resize', onResize); |
|
|
}, []); |
|
|
|
|
|
|
|
|
React.useLayoutEffect(() => { |
|
|
const ta = taRef.current; |
|
|
const ov = overlayRef.current; |
|
|
if (!ta || !ov) return; |
|
|
|
|
|
ov.style.transform = `translateY(-${ta.scrollTop}px)`; |
|
|
|
|
|
try { |
|
|
const cs = window.getComputedStyle(ta); |
|
|
ov.style.fontFamily = cs.fontFamily; |
|
|
ov.style.fontSize = cs.fontSize; |
|
|
ov.style.lineHeight = cs.lineHeight; |
|
|
ov.style.letterSpacing = cs.letterSpacing; |
|
|
ov.style.whiteSpace = cs.whiteSpace; |
|
|
} catch {} |
|
|
}, [showAnnotations, text, textareaHeight, isFullscreen]); |
|
|
|
|
|
|
|
|
const buildCorrectedText = React.useCallback((): string => { |
|
|
const base = initialTranslation || ''; |
|
|
if (!versionAnnotations.length) return base; |
|
|
const anns = versionAnnotations |
|
|
|
|
|
|
|
|
.filter(a => typeof a.correction === 'string') |
|
|
.slice() |
|
|
.sort((a, b) => a.start - b.start || a.end - b.end); |
|
|
if (!anns.length) return base; |
|
|
let result = ''; |
|
|
let pos = 0; |
|
|
for (const a of anns) { |
|
|
const start = Math.max(0, Math.min(a.start, base.length)); |
|
|
const end = Math.max(start, Math.min(a.end, base.length)); |
|
|
if (start < pos) { |
|
|
|
|
|
continue; |
|
|
} |
|
|
if (pos < start) { |
|
|
result += base.slice(pos, start); |
|
|
} |
|
|
result += a.correction as string; |
|
|
pos = end; |
|
|
} |
|
|
if (pos < base.length) { |
|
|
result += base.slice(pos); |
|
|
} |
|
|
return result; |
|
|
}, [initialTranslation, versionAnnotations]); |
|
|
|
|
|
|
|
|
React.useLayoutEffect(() => { |
|
|
const ta = taRef.current; |
|
|
const ov = overlayRef.current; |
|
|
if (!ta || !ov) return; |
|
|
ov.style.transform = `translateY(-${ta.scrollTop}px)`; |
|
|
}, [showAnnotations, text, textareaHeight, isFullscreen]); |
|
|
|
|
|
const save = async ()=>{ |
|
|
setSaving(true); |
|
|
try { |
|
|
if (onSaveEdit) { |
|
|
|
|
|
await onSaveEdit(text); |
|
|
} else { |
|
|
|
|
|
const correctedText = buildCorrectedText(); |
|
|
onSave(correctedText); |
|
|
} |
|
|
} finally { |
|
|
setSaving(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
const compareNow = async ()=>{ |
|
|
try { |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); |
|
|
|
|
|
const currentText = onSaveEdit ? text : buildCorrectedText(); |
|
|
const resp = await fetch(`${base}/api/refinity/diff`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prev: initialTranslation || '', current: currentText || '' }) }); |
|
|
const data = await resp.json().catch(()=>({})); |
|
|
if (!resp.ok) throw new Error(data?.error || 'Diff failed'); |
|
|
|
|
|
const raw = String(data?.html || ''); |
|
|
const ensured = raw.includes('<br') ? raw : raw.replace(/\n/g, '<br/>'); |
|
|
setDiffHtml(ensured); |
|
|
setShowDiff(true); |
|
|
} catch {} |
|
|
}; |
|
|
|
|
|
const downloadWithTrackChanges = async ()=>{ |
|
|
try { |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); |
|
|
const taskNameSafe = (taskTitle || 'Task').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_'); |
|
|
const userNameSafe = toSafeName(username || 'User'); |
|
|
const vNum = `v${nextVersionNumber}`; |
|
|
const filename = `${taskNameSafe}_${vNum}_Clean_${yyyymmdd_hhmm()}_${userNameSafe}.docx`; |
|
|
|
|
|
const currentText = onSaveEdit ? text : buildCorrectedText(); |
|
|
const resp = await fetch(`${base}/api/refinity/track-changes-ooxml`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prev: initialTranslation || '', current: currentText || '', filename, includeComments: false }) }); |
|
|
if (!resp.ok) throw new Error('Export failed'); |
|
|
const blob = await resp.blob(); |
|
|
const url = window.URL.createObjectURL(blob); |
|
|
const a = document.createElement('a'); |
|
|
a.href = url; |
|
|
a.download = filename; |
|
|
document.body.appendChild(a); |
|
|
a.click(); |
|
|
a.remove(); |
|
|
window.URL.revokeObjectURL(url); |
|
|
} catch { |
|
|
alert('Export failed'); |
|
|
} |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div> |
|
|
<div className="flex items-start gap-6"> |
|
|
<div className="w-1/2"> |
|
|
<div className="mb-2 text-gray-700 text-sm flex items-center justify-between"> |
|
|
<span>Source</span> |
|
|
<div className="flex items-center gap-2"> |
|
|
{!isFullscreen && ( |
|
|
<button |
|
|
className="inline-flex items-center px-2 py-1 text-xs rounded-xl opacity-0 pointer-events-none" |
|
|
> |
|
|
Full Screen |
|
|
</button> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
<div className="relative rounded-lg overflow-hidden"> |
|
|
<div className="absolute inset-0 rounded-lg" style={{ background: 'radial-gradient(120%_120%_at_0%_0%, #dbeafe 0%, #ffffff 50%, #c7d2fe 100%)' }} /> |
|
|
<div ref={sourceRef} className="relative rounded-lg bg-white/60 backdrop-blur-md ring-1 ring-inset ring-indigo-300 shadow p-4 min-h-[420px] whitespace-pre-wrap text-gray-900"> |
|
|
{source} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div className="w-1/2"> |
|
|
<div className="mb-2 text-gray-700 text-sm flex items-center justify-between"> |
|
|
<span>Translation</span> |
|
|
<div className="flex items-center gap-2"> |
|
|
{isTutorialMode && isAdminUser && !onSaveEdit && ( |
|
|
<div className="flex items-center gap-2"> |
|
|
<span className="text-xs text-gray-700">Highlights</span> |
|
|
<select |
|
|
value={adminAnnoView} |
|
|
onChange={(e)=>setAdminAnnoView(e.target.value)} |
|
|
className="h-8 px-2 text-sm rounded-lg border border-gray-300 bg-white" |
|
|
title="Select which user's highlights to display (admin-only)" |
|
|
> |
|
|
<option value={ADMIN_ANNO_VIEW_CLEAN}>Clean (none)</option> |
|
|
<option value={ADMIN_ANNO_VIEW_EDIT}>Edit (my highlights)</option> |
|
|
{annoCreators.length > 0 && <option disabled>──────────</option>} |
|
|
{annoCreators.map(u => ( |
|
|
<option key={u} value={u}>{u}</option> |
|
|
))} |
|
|
<option value={ADMIN_ANNO_VIEW_LEGACY}>Legacy (unknown)</option> |
|
|
</select> |
|
|
</div> |
|
|
)} |
|
|
{!isFullscreen && ( |
|
|
<button onClick={onToggleFullscreen} className="inline-flex items-center px-2 py-1 text-xs rounded-xl bg-white/60 ring-1 ring-gray-200 text-gray-800 hover:bg-white"> |
|
|
Full Screen |
|
|
</button> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
<div ref={wrapperRef} className="relative overflow-hidden"> |
|
|
{/* Single-surface annotated view (exact alignment) */} |
|
|
{onSaveEdit ? ( |
|
|
// Editing mode: use textarea for direct editing |
|
|
<textarea |
|
|
value={text} |
|
|
onChange={(e) => { |
|
|
setText(e.target.value); |
|
|
setDirty(true); |
|
|
}} |
|
|
className="relative z-10 w-full px-4 py-3 border border-ui-border rounded-lg bg-white text-base leading-relaxed font-sans tracking-normal whitespace-pre-wrap overflow-y-auto resize-none" |
|
|
style={{ |
|
|
minHeight: textareaHeight, |
|
|
height: textareaHeight, |
|
|
maxHeight: textareaHeight, |
|
|
}} |
|
|
/> |
|
|
) : ( |
|
|
// Revision mode: use annotated div |
|
|
<div |
|
|
ref={annotatedRef} |
|
|
className="relative z-10 w-full px-4 py-3 border border-ui-border rounded-lg bg-white text-base leading-relaxed font-sans tracking-normal whitespace-pre-wrap overflow-y-auto select-text" |
|
|
style={{ |
|
|
minHeight: textareaHeight, |
|
|
height: textareaHeight, |
|
|
maxHeight: textareaHeight, |
|
|
userSelect: 'text', |
|
|
WebkitUserSelect: 'text', |
|
|
}} |
|
|
onMouseDown={(e) => { |
|
|
// Don't clear popover on mousedown - let mouseup handle it |
|
|
// This allows selection to work properly |
|
|
}} |
|
|
onMouseUp={(e) => { |
|
|
// Use setTimeout to ensure selection is stable |
|
|
setTimeout(() => { |
|
|
const sel = window.getSelection(); |
|
|
if (!sel || sel.rangeCount === 0) { |
|
|
setPopover(null); |
|
|
return; |
|
|
} |
|
|
|
|
|
const res = getOffsetsFromSelection(); |
|
|
if (!res) { |
|
|
setPopover(null); |
|
|
return; |
|
|
} |
|
|
|
|
|
const { start, end } = res; |
|
|
if (end - start <= 0) { |
|
|
// collapsed caret: if inside an existing annotation, open edit modal |
|
|
const hit = versionAnnotations.find(a => start >= a.start && start <= a.end); |
|
|
if (hit) { |
|
|
setEditingAnn(hit); |
|
|
setModalCategory(hit.category); |
|
|
setModalComment(hit.comment || ''); |
|
|
const selected = text.slice(hit.start, hit.end); |
|
|
setModalSelectedText(selected); |
|
|
setModalCorrection(hit.correction ?? selected); |
|
|
// If correction is empty-string, treat it as a deletion annotation. |
|
|
setModalIsDeletion(typeof hit.correction === 'string' && hit.correction.trim() === ''); |
|
|
setModalSourceSnippet( |
|
|
computeSourceSnippetForOffset(source, text, hit.start) |
|
|
); |
|
|
setShowSourcePane(false); |
|
|
setModalOpen(true); |
|
|
return; |
|
|
} |
|
|
setPopover(null); |
|
|
return; |
|
|
} |
|
|
if (!adminCanEditAnnotations) { |
|
|
// View-only mode: allow inspecting existing highlights (handled above), |
|
|
// but do not allow creating new highlights via selection. |
|
|
setPopover(null); |
|
|
return; |
|
|
} |
|
|
updateSelectionPopover(); |
|
|
}, 10); |
|
|
}} |
|
|
onKeyUp={()=>{ |
|
|
setTimeout(() => { |
|
|
updateSelectionPopover(); |
|
|
}, 10); |
|
|
}} |
|
|
dangerouslySetInnerHTML={{ __html: renderAnnotatedHtml(text) }} |
|
|
/> |
|
|
)} |
|
|
{/* + Comment popover */} |
|
|
{popover && ( |
|
|
<div className="absolute z-20" style={{ left: popover.left, top: popover.top }}> |
|
|
<button |
|
|
type="button" |
|
|
onClick={()=>{ |
|
|
if (!adminCanEditAnnotations) { showToast('Switch “Highlights” to Edit (my highlights) to add.'); return; } |
|
|
setEditingAnn(null); |
|
|
setModalCategory('distortion'); |
|
|
setModalComment(''); |
|
|
if (popover) { |
|
|
const sel = text.slice(popover.start, popover.end); |
|
|
setModalSelectedText(sel); |
|
|
setModalCorrection(sel); |
|
|
setModalIsDeletion(false); |
|
|
setModalSourceSnippet( |
|
|
computeSourceSnippetForOffset(source, text, popover.start) |
|
|
); |
|
|
} else { |
|
|
setModalSelectedText(''); |
|
|
setModalCorrection(''); |
|
|
setModalIsDeletion(false); |
|
|
setModalSourceSnippet(''); |
|
|
} |
|
|
setShowSourcePane(false); |
|
|
setModalOpen(true); |
|
|
}} |
|
|
disabled={!adminCanEditAnnotations} |
|
|
className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-emerald-700 active:translate-y-0.5 transition-all duration-200" |
|
|
> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" /> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" /> |
|
|
<span className="relative z-10">+ Comment</span> |
|
|
</button> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
<div className="mt-4 flex gap-3 relative items-center"> |
|
|
<button |
|
|
onClick={() => { setDirty(false); save(); showToast('Saved'); }} |
|
|
disabled={saving} |
|
|
className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-indigo-600/70 hover:bg-indigo-700 disabled:bg-gray-400 active:translate-y-0.5 transition-all duration-200" |
|
|
> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" /> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" /> |
|
|
{saving? 'Saving…':'Save'} |
|
|
</button> |
|
|
<div className="relative inline-block align-top"> |
|
|
<button |
|
|
ref={revBtnRef} |
|
|
onClick={(e)=>{ |
|
|
e.preventDefault(); e.stopPropagation(); |
|
|
const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); |
|
|
const pad = 8, menuW = 224, menuH = 100; |
|
|
let left = Math.min(Math.max(r.left, pad), window.innerWidth - menuW - pad); |
|
|
let top = r.bottom + pad; |
|
|
if (top + menuH > window.innerHeight - pad) top = r.top - menuH - pad; |
|
|
setRevMenuPos({ left, top }); |
|
|
setRevDownloadOpen(o=>!o); |
|
|
}} |
|
|
className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-black ring-1 ring-inset ring-white/50 backdrop-blur-md bg-white/30 active:translate-y-0.5 transition-all duration-200" |
|
|
> |
|
|
Download ▾ |
|
|
</button> |
|
|
{revDownloadOpen && revMenuPos && createPortal( |
|
|
<div style={{ position: 'fixed', left: revMenuPos.left, top: revMenuPos.top, zIndex: 10000, maxHeight: '240px', overflowY: 'auto' }} className="w-56 rounded-md border border-gray-200 bg-white shadow-lg text-left"> |
|
|
<button onClick={async()=>{ setRevDownloadOpen(false); try { const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const filename=`${toSafeName(taskTitle||'Task')}_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`; const currentText = onSaveEdit ? text : buildCorrectedText(); const body={ current: currentText||'', filename }; let resp=await fetch(`${base}/api/refinity/export-plain`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body) }); if(!resp.ok) throw new Error('Export failed'); const blob=await resp.blob(); await saveBlobToDisk(blob, filename);} catch { const currentText = onSaveEdit ? text : buildCorrectedText(); await saveTextFallback(currentText||'', `${toSafeName(taskTitle||'Task')}_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.txt`);} }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Without Annotations</button> |
|
|
<button onClick={async()=>{ setRevDownloadOpen(false); try { const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const filename=`${toSafeName(taskTitle||'Task')}_v${nextVersionNumber}_Annotated_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`; const currentText = onSaveEdit ? text : buildCorrectedText(); const list=versionAnnotations.map(a=>({ start:a.start, end:a.end, category:a.category, comment:a.comment })); const body={ current: currentText||'', filename, annotations: list }; let resp=await fetch(`${base}/api/refinity/export-plain-with-annotations`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body) }); if(!resp.ok){ resp=await fetch(`${base}/api/refinity/export-plain`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ current: currentText||'', filename }) }); } if(!resp.ok) throw new Error('Export failed'); const blob=await resp.blob(); await saveBlobToDisk(blob, filename);} catch { const currentText = onSaveEdit ? text : buildCorrectedText(); await saveTextFallback(currentText||'', `${toSafeName(taskTitle||'Task')}_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.txt`);} }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">With Annotations</button> |
|
|
</div>, document.body |
|
|
)} |
|
|
</div> |
|
|
{/* Comments button removed per request */} |
|
|
<button onClick={()=>{ if (!dirty || window.confirm('Discard unsaved changes?')) { setDirty(false); onBack(); } }} className="ml-auto relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-black ring-1 ring-inset ring-white/50 backdrop-blur-md bg-white/30 active:translate-y-0.5 transition-all duration-200">Back</button> |
|
|
</div> |
|
|
{/* Inline comments drawer removed */} |
|
|
{showDiff && ( |
|
|
<div className="mt-6"> |
|
|
<div className="relative rounded-xl"> |
|
|
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-indigo-200/45 via-indigo-100/40 to-indigo-300/45" /> |
|
|
<div className="relative rounded-xl bg-white/10 backdrop-blur-md ring-1 ring-inset ring-white/30 shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)] p-4"> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-xl opacity-50 [background:linear-gradient(to_bottom,rgba(255,255,255,0.3),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.28),rgba(255,255,255,0)_28%)]" /> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-45" /> |
|
|
<div className="relative text-gray-900 prose prose-sm max-w-none whitespace-pre-wrap" dangerouslySetInnerHTML={{ __html: diffHtml }} /> |
|
|
</div> |
|
|
</div> |
|
|
<div className="mt-4 flex justify-end"> |
|
|
<div className="relative inline-block"> |
|
|
<button |
|
|
ref={diffBtnRef} |
|
|
onClick={(e)=>{ |
|
|
e.preventDefault(); e.stopPropagation(); |
|
|
const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); |
|
|
const pad = 8, menuW = 200; |
|
|
let left = Math.min(Math.max(r.left, pad), window.innerWidth - menuW - pad); |
|
|
let top = r.bottom + pad; |
|
|
if (top + 150 > window.innerHeight - pad) top = r.top - 150 - pad; |
|
|
setDiffMenuPos({ left, top }); |
|
|
setDiffDownloadOpen(o=>!o); |
|
|
}} |
|
|
className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-black ring-1 ring-inset ring-white/50 backdrop-blur-md bg-white/30 active:translate-y-0.5 transition-all duration-200" |
|
|
> |
|
|
Download ▾ |
|
|
</button> |
|
|
{diffDownloadOpen && diffMenuPos && createPortal( |
|
|
<div style={{ position: 'fixed', left: diffMenuPos.left, top: diffMenuPos.top, zIndex: 10000 }} className="w-52 rounded-md border border-gray-200 bg-white shadow-lg text-left"> |
|
|
<button onClick={async()=>{ |
|
|
setDiffDownloadOpen(false); |
|
|
try { |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); |
|
|
const correctedText = buildCorrectedText(); |
|
|
const filename = `${toSafeName(taskTitle||'Task')}_Compare_Inline_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`; |
|
|
const body = { prev: initialTranslation||'', current: correctedText||'', filename, authorName: username||'User' }; |
|
|
let resp = await fetch(`${base}/api/refinity/track-changes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); |
|
|
if (!resp.ok) { |
|
|
resp = await fetch(`${base}/api/refinity/export-plain`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current: correctedText||'', filename }) }); |
|
|
} |
|
|
if (!resp.ok) throw new Error('Export failed'); |
|
|
const blob = await resp.blob(); |
|
|
const url = window.URL.createObjectURL(blob); |
|
|
const link = document.createElement('a'); |
|
|
link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); |
|
|
} catch {} |
|
|
}} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Inline Changes</button> |
|
|
<button onClick={async()=>{ |
|
|
setDiffDownloadOpen(false); |
|
|
try { |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); |
|
|
const correctedText = buildCorrectedText(); |
|
|
const filename = `${toSafeName(taskTitle||'Task')}_Compare_Tracked_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`; |
|
|
const body = { prev: initialTranslation||'', current: correctedText||'', filename, authorName: username||'User', includeComments: false }; |
|
|
let resp = await fetch(`${base}/api/refinity/track-changes-ooxml`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); |
|
|
if (!resp.ok) { |
|
|
resp = await fetch(`${base}/api/refinity/export-plain`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current: correctedText||'', filename }) }); |
|
|
} |
|
|
if (!resp.ok) throw new Error('Export failed'); |
|
|
const blob = await resp.blob(); |
|
|
const url = window.URL.createObjectURL(blob); |
|
|
const link = document.createElement('a'); |
|
|
link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); |
|
|
} catch {} |
|
|
}} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Tracked Changes</button> |
|
|
<button onClick={async()=>{ |
|
|
setDiffDownloadOpen(false); |
|
|
try { |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/,''); |
|
|
const correctedText = buildCorrectedText(); |
|
|
const filename = `${toSafeName(taskTitle||'Task')}_Compare_SidebarComments_${yyyymmdd_hhmm()}_${toSafeName(username||'User')}.docx`; |
|
|
const body: any = { |
|
|
prev: initialTranslation || '', |
|
|
current: correctedText || '', |
|
|
filename, |
|
|
authorName: username || 'User', |
|
|
annotationVersionId: versionId, |
|
|
}; |
|
|
let resp = await fetch(getApiBase('/compare-comments-with-corrections'), { |
|
|
method: 'POST', |
|
|
headers: (() => { |
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}'); |
|
|
const headers: any = { 'Content-Type': 'application/json' }; |
|
|
if (user.name) headers['x-user-name'] = user.name; |
|
|
if (user.email) headers['x-user-email'] = user.email; |
|
|
if (user.role) { |
|
|
headers['x-user-role'] = user.role; |
|
|
headers['user-role'] = user.role; |
|
|
} |
|
|
return headers; |
|
|
})(), |
|
|
body: JSON.stringify(body), |
|
|
}); |
|
|
if (!resp.ok) { |
|
|
resp = await fetch(getApiBase('/export-plain'), { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ current: initialTranslation || '', filename }), |
|
|
}); |
|
|
} |
|
|
if (!resp.ok) throw new Error('Export failed'); |
|
|
const blob = await resp.blob(); |
|
|
const url = window.URL.createObjectURL(blob); |
|
|
const link = document.createElement('a'); |
|
|
link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); |
|
|
} catch {} |
|
|
}} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Sidebar Comments</button> |
|
|
</div>, document.body |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
{toastMsg && ( |
|
|
<div className="fixed bottom-5 right-5 z-[6000] px-3 py-2 rounded-lg bg-black/80 text-white text-sm shadow-lg">{toastMsg}</div> |
|
|
)} |
|
|
{/* Annotation modal */} |
|
|
{modalOpen && ( |
|
|
<div className="fixed inset-0 z-[5000] flex items-center justify-center bg-black/40"> |
|
|
<motion.div |
|
|
initial={false} |
|
|
animate={{ maxWidth: showSourcePane && modalSourceSnippet ? 900 : 480 }} |
|
|
transition={{ duration: 0.2, ease: 'easeOut' }} |
|
|
className="bg-white rounded-xl shadow-xl w-full p-6" |
|
|
> |
|
|
<div className="flex items-center justify-between mb-4"> |
|
|
<h3 className="text-lg font-semibold text-gray-900">{editingAnn ? 'Edit annotation' : 'New annotation'}</h3> |
|
|
{modalSourceSnippet && ( |
|
|
<button |
|
|
type="button" |
|
|
onClick={() => setShowSourcePane(v => !v)} |
|
|
className="text-xs text-indigo-700 hover:text-indigo-900 underline-offset-2 hover:underline" |
|
|
> |
|
|
{showSourcePane ? 'Hide source' : 'Show source'} |
|
|
</button> |
|
|
)} |
|
|
</div> |
|
|
<div className="space-y-4"> |
|
|
<div className="md:flex md:space-x-4 space-y-4 md:space-y-0 items-start"> |
|
|
<AnimatePresence initial={false}> |
|
|
{showSourcePane && modalSourceSnippet && ( |
|
|
<motion.div |
|
|
key="source-pane" |
|
|
initial={{ opacity: 0, x: -10 }} |
|
|
animate={{ opacity: 1, x: 0 }} |
|
|
exit={{ opacity: 0, x: -10 }} |
|
|
transition={{ duration: 0.2, ease: 'easeOut' }} |
|
|
className="md:w-2/5 w-full space-y-1" |
|
|
> |
|
|
<label className="block text-sm text-gray-700"> |
|
|
Source paragraph |
|
|
</label> |
|
|
<div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm text-gray-800 whitespace-pre-wrap overflow-auto max-h-64"> |
|
|
{modalSourceSnippet} |
|
|
</div> |
|
|
</motion.div> |
|
|
)} |
|
|
</AnimatePresence> |
|
|
<div className="flex-1 md:w-3/5 space-y-4"> |
|
|
<div> |
|
|
<label className="block text-sm text-gray-700 mb-1">Selected text</label> |
|
|
<div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm text-gray-800 whitespace-pre-wrap max-h-32 overflow-auto"> |
|
|
{modalSelectedText || '(no selection)'} |
|
|
</div> |
|
|
</div> |
|
|
<div> |
|
|
<label className="block text-sm text-gray-700 mb-1">Error type <span className="text-red-500">*</span></label> |
|
|
<select |
|
|
value={modalCategory} |
|
|
onChange={(e)=>setModalCategory(e.target.value as AnnotationCategory)} |
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm" |
|
|
> |
|
|
<option value="distortion">Distortion</option> |
|
|
<option value="omission">Unjustified omission</option> |
|
|
<option value="register">Inappropriate register</option> |
|
|
<option value="unidiomatic">Unidiomatic expression</option> |
|
|
<option value="grammar">Error of grammar, syntax</option> |
|
|
<option value="spelling">Error of spelling</option> |
|
|
<option value="punctuation">Error of punctuation</option> |
|
|
<option value="addition">Unjustified addition</option> |
|
|
<option value="other">Other</option> |
|
|
</select> |
|
|
</div> |
|
|
<div> |
|
|
<div className="mb-3"> |
|
|
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer"> |
|
|
<input |
|
|
type="checkbox" |
|
|
checked={modalIsDeletion} |
|
|
onChange={(e) => { |
|
|
setModalIsDeletion(e.target.checked); |
|
|
if (e.target.checked) { |
|
|
setModalCorrection(''); // Clear correction when marking as deletion |
|
|
} |
|
|
}} |
|
|
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500" |
|
|
/> |
|
|
<span>Delete this text</span> |
|
|
</label> |
|
|
</div> |
|
|
{!modalIsDeletion && ( |
|
|
<> |
|
|
<label className="block text-sm text-gray-700 mb-1">Correction <span className="text-red-500">*</span></label> |
|
|
<textarea |
|
|
value={modalCorrection} |
|
|
onChange={(e)=>setModalCorrection(e.target.value)} |
|
|
rows={2} |
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm mb-3" |
|
|
placeholder="Enter the corrected wording for this span…" |
|
|
/> |
|
|
</> |
|
|
)} |
|
|
{modalIsDeletion && ( |
|
|
<div className="mb-3 p-3 bg-red-50 border border-red-200 rounded-md"> |
|
|
<p className="text-sm text-red-700">This text will be deleted. The selected text will be removed from the final version.</p> |
|
|
</div> |
|
|
)} |
|
|
<label className="block text-sm text-gray-700 mb-1">Comment (optional)</label> |
|
|
<textarea |
|
|
value={modalComment} |
|
|
onChange={(e)=>setModalComment(e.target.value)} |
|
|
rows={3} |
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm" |
|
|
placeholder="Enter the explanation or feedback…" |
|
|
/> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div className="mt-6 flex items-center justify-between"> |
|
|
{editingAnn && ( |
|
|
<button |
|
|
onClick={async()=>{ |
|
|
if (!adminCanEditAnnotations) { showToast('Switch “Highlights” to Edit (my highlights) to modify.'); return; } |
|
|
await persistDelete(editingAnn.id); |
|
|
deleteAnnotationById(editingAnn.id); |
|
|
setModalOpen(false); setPopover(null); setEditingAnn(null); setModalIsDeletion(false); |
|
|
}} |
|
|
disabled={!adminCanEditAnnotations} |
|
|
className={`px-3 py-1.5 text-sm rounded-lg text-white ${adminCanEditAnnotations ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-400 cursor-not-allowed'}`} |
|
|
> |
|
|
Delete |
|
|
</button> |
|
|
)} |
|
|
<div className="ml-auto flex gap-2"> |
|
|
<button onClick={()=>{ setModalOpen(false); setPopover(null); setEditingAnn(null); setModalIsDeletion(false); }} className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 text-gray-700 bg-white hover:bg-gray-50">Cancel</button> |
|
|
<button |
|
|
onClick={async ()=>{ |
|
|
if (!adminCanEditAnnotations) { showToast('Switch “Highlights” to Edit (my highlights) to save changes.'); return; } |
|
|
// Allow empty correction only if deletion is checked |
|
|
if (!modalIsDeletion && !modalCorrection.trim()) { |
|
|
alert('Please enter a correction for this highlighted text, or check "Delete this text" to mark it for deletion.'); |
|
|
return; |
|
|
} |
|
|
if (editingAnn) { |
|
|
const updated = { |
|
|
...editingAnn, |
|
|
category: modalCategory, |
|
|
comment: modalComment, |
|
|
correction: modalIsDeletion ? '' : modalCorrection, |
|
|
updatedAt: Date.now() |
|
|
}; |
|
|
updateAnnotation(updated); |
|
|
await persistUpdate(updated); |
|
|
} else if (popover) { |
|
|
const local = { |
|
|
id:`a_${Date.now()}_${Math.random().toString(36).slice(2,7)}`, |
|
|
versionId, |
|
|
start: popover.start, |
|
|
end: popover.end, |
|
|
category: modalCategory, |
|
|
comment: modalComment, |
|
|
correction: modalIsDeletion ? '' : modalCorrection, |
|
|
createdAt: Date.now(), |
|
|
updatedAt: Date.now() |
|
|
}; |
|
|
const saved = await persistCreate(local); |
|
|
addAnnotation(saved); |
|
|
} |
|
|
setModalOpen(false); setPopover(null); setEditingAnn(null); setModalIsDeletion(false); |
|
|
}} |
|
|
disabled={!adminCanEditAnnotations} |
|
|
className={`px-3 py-1.5 text-sm rounded-lg text-white ${adminCanEditAnnotations ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-gray-400 cursor-not-allowed'}`} |
|
|
> |
|
|
Save |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</motion.div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default Refinity; |
|
|
|
|
|
const PreviewPane: React.FC<{ version: Version | null; onBack: ()=>void; onEdit: ()=>void; compareInitially?: boolean }>=({ version, onBack, onEdit, compareInitially })=>{ |
|
|
const [diffHtml, setDiffHtml] = React.useState<string>(''); |
|
|
const [showDiff, setShowDiff] = React.useState<boolean>(!!compareInitially); |
|
|
React.useEffect(()=>{ |
|
|
setShowDiff(!!compareInitially); |
|
|
},[compareInitially]); |
|
|
if (!version) return ( |
|
|
<div> |
|
|
<div className="text-gray-700">No version selected.</div> |
|
|
<div className="mt-3"> |
|
|
<button onClick={onBack} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-black ring-1 ring-inset ring-white/50 backdrop-blur-md bg-white/30 active:translate-y-0.5 transition-all duration-200">Back</button> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
const doCompare = async ()=>{ |
|
|
try { |
|
|
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); |
|
|
|
|
|
const prevText = (version as any)?.parentContent || ''; |
|
|
const resp = await fetch(`${base}/api/refinity/diff`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prev: prevText, current: version.content || '' }) }); |
|
|
const data = await resp.json().catch(()=>({})); |
|
|
if (!resp.ok) throw new Error(data?.error || 'Diff failed'); |
|
|
setDiffHtml(String(data?.html || '')); |
|
|
setShowDiff(true); |
|
|
} catch {} |
|
|
}; |
|
|
return ( |
|
|
<div> |
|
|
<div className="relative rounded-xl ring-1 ring-gray-200 shadow-sm overflow-hidden"> |
|
|
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-orange-50 via-amber-50 to-white" /> |
|
|
<div className="relative rounded-xl p-6"> |
|
|
{!showDiff && ( |
|
|
<div className="relative whitespace-pre-wrap text-gray-900 min-h-[220px]">{version.content}</div> |
|
|
)} |
|
|
{showDiff && ( |
|
|
<div className="relative prose prose-sm max-w-none text-gray-900" dangerouslySetInnerHTML={{ __html: diffHtml }} /> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
<div className="mt-4 flex gap-3"> |
|
|
<button |
|
|
onClick={onEdit} |
|
|
className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-orange-600/70 hover:bg-orange-700 active:translate-y-0.5 transition-all duration-200" |
|
|
> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" /> |
|
|
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" /> |
|
|
<span className="relative z-10">Revise</span> |
|
|
</button> |
|
|
<button onClick={onBack} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-black ring-1 ring-inset ring-white/50 backdrop-blur-md bg-white/30 active:translate-y-0.5 transition-all duration-200">Back</button> |
|
|
{/* Compare removed in revision mode per request */} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
|