Upload folder using huggingface_hub
Browse files- client/src/components/Layout.tsx +21 -21
- client/src/components/Refinity.tsx +523 -38
- client/src/pages/Toolkit.tsx +4 -4
- client/src/pages/TutorialTasks.tsx +5 -5
- client/src/pages/WeeklyPractice.tsx +192 -192
client/src/components/Layout.tsx
CHANGED
|
@@ -145,8 +145,8 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
| 145 |
|
| 146 |
const handleLogout = () => {
|
| 147 |
try {
|
| 148 |
-
|
| 149 |
-
|
| 150 |
} catch {}
|
| 151 |
window.location.href = '/login';
|
| 152 |
};
|
|
@@ -201,9 +201,9 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
| 201 |
<Link to="/dashboard" className="text-[1.6rem] font-bold text-ui-text flex items-center -ml-4 hover:text-ui-text" style={{ fontFamily: 'Lobster, Inter, system-ui, sans-serif' }}>
|
| 202 |
<img src="/favicon-512x512.png" alt="logo" className="h-8 w-8 mr-2" />
|
| 203 |
TransHub
|
| 204 |
-
|
| 205 |
<div />
|
| 206 |
-
|
| 207 |
</header>
|
| 208 |
|
| 209 |
{/* Shell: Sidebar + Content */}
|
|
@@ -211,43 +211,43 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
| 211 |
{/* Sidebar */}
|
| 212 |
<aside className="hidden md:flex md:flex-col w-60 fixed top-14 left-0 bottom-0 border-r border-ui-border bg-ui-panel/80 backdrop-blur z-30 sidebar-shell">
|
| 213 |
<nav className="p-4 space-y-2 flex-1 sidebar-nav">
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
className={`flex items-center px-3 py-2 rounded-lg text-[0.95rem] font-medium transition-colors ${
|
| 221 |
isActive ? 'text-ui-text' : 'text-ui-text/80 hover:text-ui-text'
|
| 222 |
-
|
| 223 |
-
|
| 224 |
<img src={iconSrcFor(item.name)} alt="" className="h-6 w-6 mr-3" />
|
| 225 |
<span>{item.name}</span>
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
</nav>
|
| 230 |
<div className="p-3 border-t border-ui-border mt-auto sidebar-footer">
|
| 231 |
{user ? (
|
| 232 |
<button onClick={handleLogout} className="w-full flex items-center justify-start px-3 py-2 rounded-md text-sm font-medium text-ui-text/80 hover:bg-ui-panel/60">
|
| 233 |
<PowerIcon className="h-4 w-4 mr-2" />
|
| 234 |
Log Out
|
| 235 |
-
|
| 236 |
) : (
|
| 237 |
<Link to="/login" className="w-full flex items-center justify-start px-3 py-2 rounded-md text-sm font-medium text-ui-text/80 hover:bg-ui-panel/60">
|
| 238 |
<PowerIcon className="h-4 w-4 mr-2" />
|
| 239 |
Log In
|
| 240 |
</Link>
|
| 241 |
)}
|
| 242 |
-
|
| 243 |
</aside>
|
| 244 |
|
| 245 |
-
|
| 246 |
<main className="flex-1 p-4 sm:p-6 lg:p-8 md:ml-60">
|
| 247 |
-
|
| 248 |
-
|
| 249 |
</div>
|
| 250 |
-
|
| 251 |
{/* Transition Loading Indicator */}
|
| 252 |
{isTransitioning && (
|
| 253 |
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50">
|
|
|
|
| 145 |
|
| 146 |
const handleLogout = () => {
|
| 147 |
try {
|
| 148 |
+
localStorage.removeItem('token');
|
| 149 |
+
localStorage.removeItem('user');
|
| 150 |
} catch {}
|
| 151 |
window.location.href = '/login';
|
| 152 |
};
|
|
|
|
| 201 |
<Link to="/dashboard" className="text-[1.6rem] font-bold text-ui-text flex items-center -ml-4 hover:text-ui-text" style={{ fontFamily: 'Lobster, Inter, system-ui, sans-serif' }}>
|
| 202 |
<img src="/favicon-512x512.png" alt="logo" className="h-8 w-8 mr-2" />
|
| 203 |
TransHub
|
| 204 |
+
</Link>
|
| 205 |
<div />
|
| 206 |
+
</div>
|
| 207 |
</header>
|
| 208 |
|
| 209 |
{/* Shell: Sidebar + Content */}
|
|
|
|
| 211 |
{/* Sidebar */}
|
| 212 |
<aside className="hidden md:flex md:flex-col w-60 fixed top-14 left-0 bottom-0 border-r border-ui-border bg-ui-panel/80 backdrop-blur z-30 sidebar-shell">
|
| 213 |
<nav className="p-4 space-y-2 flex-1 sidebar-nav">
|
| 214 |
+
{navigation.map((item) => {
|
| 215 |
+
const isActive = location.pathname === item.href;
|
| 216 |
+
return (
|
| 217 |
+
<Link
|
| 218 |
+
key={item.name}
|
| 219 |
+
to={item.href}
|
| 220 |
className={`flex items-center px-3 py-2 rounded-lg text-[0.95rem] font-medium transition-colors ${
|
| 221 |
isActive ? 'text-ui-text' : 'text-ui-text/80 hover:text-ui-text'
|
| 222 |
+
}`}
|
| 223 |
+
>
|
| 224 |
<img src={iconSrcFor(item.name)} alt="" className="h-6 w-6 mr-3" />
|
| 225 |
<span>{item.name}</span>
|
| 226 |
+
</Link>
|
| 227 |
+
);
|
| 228 |
+
})}
|
| 229 |
</nav>
|
| 230 |
<div className="p-3 border-t border-ui-border mt-auto sidebar-footer">
|
| 231 |
{user ? (
|
| 232 |
<button onClick={handleLogout} className="w-full flex items-center justify-start px-3 py-2 rounded-md text-sm font-medium text-ui-text/80 hover:bg-ui-panel/60">
|
| 233 |
<PowerIcon className="h-4 w-4 mr-2" />
|
| 234 |
Log Out
|
| 235 |
+
</button>
|
| 236 |
) : (
|
| 237 |
<Link to="/login" className="w-full flex items-center justify-start px-3 py-2 rounded-md text-sm font-medium text-ui-text/80 hover:bg-ui-panel/60">
|
| 238 |
<PowerIcon className="h-4 w-4 mr-2" />
|
| 239 |
Log In
|
| 240 |
</Link>
|
| 241 |
)}
|
| 242 |
+
</div>
|
| 243 |
</aside>
|
| 244 |
|
| 245 |
+
{/* Main Content */}
|
| 246 |
<main className="flex-1 p-4 sm:p-6 lg:p-8 md:ml-60">
|
| 247 |
+
{!isTransitioning && children}
|
| 248 |
+
</main>
|
| 249 |
</div>
|
| 250 |
+
|
| 251 |
{/* Transition Loading Indicator */}
|
| 252 |
{isTransitioning && (
|
| 253 |
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50">
|
client/src/components/Refinity.tsx
CHANGED
|
@@ -22,6 +22,28 @@ type Version = {
|
|
| 22 |
parentVersionId?: string; // lineage
|
| 23 |
};
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
type Task = {
|
| 26 |
id: string;
|
| 27 |
title: string;
|
|
@@ -53,6 +75,13 @@ const Refinity: React.FC = () => {
|
|
| 53 |
const [compareMenuPos, setCompareMenuPos] = React.useState<{ left: number; top: number } | null>(null);
|
| 54 |
const compareBtnRef = React.useRef<HTMLButtonElement | null>(null);
|
| 55 |
const [revDownloadOpen, setRevDownloadOpen] = React.useState<boolean>(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
const [username] = React.useState<string>(() => {
|
| 57 |
try {
|
| 58 |
const u = localStorage.getItem('user');
|
|
@@ -233,6 +262,98 @@ const Refinity: React.FC = () => {
|
|
| 233 |
}
|
| 234 |
};
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
// Flow slide text overflow measurement for multi-line ellipsis indicator
|
| 237 |
const textRefs = React.useRef<{ [id: string]: HTMLDivElement | null }>({});
|
| 238 |
const [overflowMap, setOverflowMap] = React.useState<{ [id: string]: boolean }>({});
|
|
@@ -406,6 +527,84 @@ const Refinity: React.FC = () => {
|
|
| 406 |
<button onClick={() => setIsFullscreen(false)} className="px-3 py-1.5 text-sm rounded-2xl border border-gray-200 bg-white">Exit Full Screen</button>
|
| 407 |
</div>
|
| 408 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
{/* Clean container (no purple gradient) */}
|
| 410 |
<div className={isFullscreen ? 'relative mx-auto max-w-6xl p-6' : 'relative rounded-xl p-6 bg-transparent'}>
|
| 411 |
{/* Stage 1: Task Creation / Selection */}
|
|
@@ -627,9 +826,65 @@ const Refinity: React.FC = () => {
|
|
| 627 |
ref={(el)=>{ textRefs.current[v.id]=el; }}
|
| 628 |
className={`text-gray-900 whitespace-pre-wrap break-words leading-relaxed flex-1 overflow-hidden pr-1 relative`}
|
| 629 |
style={{ maxHeight: 'calc(100% - 10.5rem)', paddingBottom: '0.25rem' }}
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
{overflowMap[v.id] && (
|
| 634 |
<div className="pointer-events-none absolute text-gray-700" style={{ left: '1.5rem', bottom: '6rem', zIndex: 2000 }}>…</div>
|
| 635 |
)}
|
|
@@ -914,6 +1169,7 @@ const Refinity: React.FC = () => {
|
|
| 914 |
nextVersionNumber={((versions.filter(v=>v.taskId===(task?.id||'')).slice(-1)[0]?.versionNumber) || 0) + 1}
|
| 915 |
isFullscreen={isFullscreen}
|
| 916 |
onToggleFullscreen={()=>setIsFullscreen(v=>!v)}
|
|
|
|
| 917 |
/>
|
| 918 |
)}
|
| 919 |
</div>
|
|
@@ -921,7 +1177,7 @@ const Refinity: React.FC = () => {
|
|
| 921 |
);
|
| 922 |
};
|
| 923 |
|
| 924 |
-
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 }>=({ source, initialTranslation, onBack, onSave, onSaveEdit, taskTitle, username, nextVersionNumber, isFullscreen, onToggleFullscreen })=>{
|
| 925 |
const [text, setText] = React.useState<string>(initialTranslation);
|
| 926 |
const [saving, setSaving] = React.useState(false);
|
| 927 |
const [diffHtml, setDiffHtml] = React.useState<string>('');
|
|
@@ -934,6 +1190,132 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
|
|
| 934 |
const [commentsOpen, setCommentsOpen] = React.useState(false);
|
| 935 |
const [newComment, setNewComment] = React.useState('');
|
| 936 |
const [comments, setComments] = React.useState<Array<{ id: string; text: string; ts: number }>>([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 937 |
|
| 938 |
React.useEffect(() => {
|
| 939 |
if (sourceRef.current) {
|
|
@@ -1026,48 +1408,94 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
|
|
| 1026 |
<div className="w-1/2">
|
| 1027 |
<div className="mb-2 text-gray-700 text-sm flex items-center justify-between">
|
| 1028 |
<span>Translation</span>
|
| 1029 |
-
|
| 1030 |
-
<
|
| 1031 |
-
|
| 1032 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1033 |
)}
|
| 1034 |
</div>
|
| 1035 |
-
<textarea
|
| 1036 |
-
value={text}
|
| 1037 |
-
onChange={(e)=>setText(e.target.value)}
|
| 1038 |
-
className="relative z-10 w-full px-4 py-3 border border-ui-border rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
|
| 1039 |
-
style={{
|
| 1040 |
-
minHeight: textareaHeight,
|
| 1041 |
-
height: textareaHeight,
|
| 1042 |
-
maxHeight: textareaHeight,
|
| 1043 |
-
resize: 'none',
|
| 1044 |
-
overflowY: 'auto'
|
| 1045 |
-
}}
|
| 1046 |
-
/>
|
| 1047 |
<div className="mt-4 flex gap-3 relative">
|
| 1048 |
<button onClick={save} disabled={saving} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-indigo-600/70 disabled:bg-gray-400 active:translate-y-0.5 transition-all duration-200">{saving? 'Saving…':'Save'}</button>
|
| 1049 |
<div className="relative inline-block align-top">
|
| 1050 |
-
<button ref={revBtnRef} onClick={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1051 |
</div>
|
| 1052 |
-
|
| 1053 |
<button onClick={onBack} className="ml-auto relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-black ring-1 ring-inset ring-white/50 backdrop-blur-md bg-white/30 active:translate-y-0.5 transition-all duration-200">Back</button>
|
| 1054 |
</div>
|
| 1055 |
-
{
|
| 1056 |
-
<div className="mt-3 rounded-xl border border-gray-200 bg-white/60 p-3">
|
| 1057 |
-
<div className="text-sm text-gray-800 mb-2">Add a brief comment (optional)</div>
|
| 1058 |
-
<div className="flex gap-2">
|
| 1059 |
-
<input value={newComment} onChange={(e)=>setNewComment(e.target.value)} className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white text-sm" placeholder="Your comment…" />
|
| 1060 |
-
<button onClick={()=>{ const t=newComment.trim(); if(t){ setComments(prev=>[{ id: String(Date.now()), text: t, ts: Date.now() }, ...prev]); setNewComment(''); } }} className="px-3 py-2 text-xs rounded-xl bg-indigo-600/70 text-white hover:bg-indigo-700">Add</button>
|
| 1061 |
-
</div>
|
| 1062 |
-
{comments.length>0 && (
|
| 1063 |
-
<div className="mt-3 space-y-2 max-h-40 overflow-auto">
|
| 1064 |
-
{comments.map(c=>(
|
| 1065 |
-
<div key={c.id} className="text-xs text-gray-700 bg-white rounded-lg border border-gray-200 p-2">{c.text}</div>
|
| 1066 |
-
))}
|
| 1067 |
-
</div>
|
| 1068 |
-
)}
|
| 1069 |
-
</div>
|
| 1070 |
-
)}
|
| 1071 |
{showDiff && (
|
| 1072 |
<div className="mt-6 relative rounded-xl">
|
| 1073 |
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-indigo-200/45 via-indigo-100/40 to-indigo-300/45" />
|
|
@@ -1080,6 +1508,63 @@ const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack:
|
|
| 1080 |
)}
|
| 1081 |
</div>
|
| 1082 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1083 |
</div>
|
| 1084 |
);
|
| 1085 |
};
|
|
|
|
| 22 |
parentVersionId?: string; // lineage
|
| 23 |
};
|
| 24 |
|
| 25 |
+
type AnnotationCategory =
|
| 26 |
+
| 'distortion'
|
| 27 |
+
| 'omission'
|
| 28 |
+
| 'register'
|
| 29 |
+
| 'unidiomatic'
|
| 30 |
+
| 'grammar'
|
| 31 |
+
| 'spelling'
|
| 32 |
+
| 'punctuation'
|
| 33 |
+
| 'addition'
|
| 34 |
+
| 'other';
|
| 35 |
+
|
| 36 |
+
type Annotation = {
|
| 37 |
+
id: string;
|
| 38 |
+
versionId: string;
|
| 39 |
+
start: number; // inclusive, char index in version.content
|
| 40 |
+
end: number; // exclusive
|
| 41 |
+
category: AnnotationCategory;
|
| 42 |
+
comment?: string;
|
| 43 |
+
createdAt: number;
|
| 44 |
+
updatedAt: number;
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
type Task = {
|
| 48 |
id: string;
|
| 49 |
title: string;
|
|
|
|
| 75 |
const [compareMenuPos, setCompareMenuPos] = React.useState<{ left: number; top: number } | null>(null);
|
| 76 |
const compareBtnRef = React.useRef<HTMLButtonElement | null>(null);
|
| 77 |
const [revDownloadOpen, setRevDownloadOpen] = React.useState<boolean>(false);
|
| 78 |
+
// Annotations (Version Flow)
|
| 79 |
+
const [showAnnotations, setShowAnnotations] = React.useState<boolean>(true);
|
| 80 |
+
const [annotationPopover, setAnnotationPopover] = React.useState<{ left: number; top: number; versionId: string; range: { start: number; end: number } } | null>(null);
|
| 81 |
+
const [annotationModalOpen, setAnnotationModalOpen] = React.useState<boolean>(false);
|
| 82 |
+
const [editingAnnotation, setEditingAnnotation] = React.useState<Annotation | null>(null);
|
| 83 |
+
const [modalCategory, setModalCategory] = React.useState<AnnotationCategory>('distortion');
|
| 84 |
+
const [modalComment, setModalComment] = React.useState<string>('');
|
| 85 |
const [username] = React.useState<string>(() => {
|
| 86 |
try {
|
| 87 |
const u = localStorage.getItem('user');
|
|
|
|
| 262 |
}
|
| 263 |
};
|
| 264 |
|
| 265 |
+
// ---------- Annotation helpers ----------
|
| 266 |
+
const ANNO_STORE_KEY = 'refinity_annotations_v1';
|
| 267 |
+
const loadAnnotations = React.useCallback((): Annotation[] => {
|
| 268 |
+
try {
|
| 269 |
+
const raw = localStorage.getItem(ANNO_STORE_KEY);
|
| 270 |
+
const arr = raw ? JSON.parse(raw) : [];
|
| 271 |
+
return Array.isArray(arr) ? arr : [];
|
| 272 |
+
} catch { return []; }
|
| 273 |
+
}, []);
|
| 274 |
+
const saveAnnotations = React.useCallback((list: Annotation[]) => {
|
| 275 |
+
try { localStorage.setItem(ANNO_STORE_KEY, JSON.stringify(list)); } catch {}
|
| 276 |
+
}, []);
|
| 277 |
+
const [annotations, setAnnotations] = React.useState<Annotation[]>(() => loadAnnotations());
|
| 278 |
+
React.useEffect(() => { saveAnnotations(annotations); }, [annotations, saveAnnotations]);
|
| 279 |
+
const annoForVersion = React.useCallback((versionId: string) => annotations.filter(a => a.versionId === versionId), [annotations]);
|
| 280 |
+
const addAnnotation = React.useCallback((a: Annotation) => setAnnotations(prev => [...prev, a]), []);
|
| 281 |
+
const updateAnnotation = React.useCallback((a: Annotation) => setAnnotations(prev => prev.map(x => x.id === a.id ? a : x)), []);
|
| 282 |
+
const deleteAnnotationById = React.useCallback((id: string) => setAnnotations(prev => prev.filter(a => a.id !== id)), []);
|
| 283 |
+
|
| 284 |
+
const CATEGORY_LABELS: Record<AnnotationCategory, string> = {
|
| 285 |
+
distortion: 'Distortion',
|
| 286 |
+
omission: 'Unjustified omission',
|
| 287 |
+
register: 'Inappropriate register',
|
| 288 |
+
unidiomatic: 'Unidiomatic expression',
|
| 289 |
+
grammar: 'Error of grammar, syntax',
|
| 290 |
+
spelling: 'Error of spelling',
|
| 291 |
+
punctuation: 'Error of punctuation',
|
| 292 |
+
addition: 'Unjustified addition',
|
| 293 |
+
other: 'Other',
|
| 294 |
+
};
|
| 295 |
+
const CATEGORY_CLASS: Record<AnnotationCategory, string> = {
|
| 296 |
+
distortion: 'bg-rose-100',
|
| 297 |
+
omission: 'bg-amber-100',
|
| 298 |
+
register: 'bg-indigo-100',
|
| 299 |
+
unidiomatic: 'bg-purple-100',
|
| 300 |
+
grammar: 'bg-blue-100',
|
| 301 |
+
spelling: 'bg-green-100',
|
| 302 |
+
punctuation: 'bg-teal-100',
|
| 303 |
+
addition: 'bg-pink-100',
|
| 304 |
+
other: 'bg-gray-100',
|
| 305 |
+
};
|
| 306 |
+
function escapeHtml(s: string): string {
|
| 307 |
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
| 308 |
+
}
|
| 309 |
+
function renderAnnotatedHtml(text: string, versionId: string, enabled: boolean): string {
|
| 310 |
+
if (!enabled) return escapeHtml(text);
|
| 311 |
+
const list = annoForVersion(versionId).slice().sort((a,b)=>a.start-b.start);
|
| 312 |
+
if (!list.length) return escapeHtml(text);
|
| 313 |
+
let html = '';
|
| 314 |
+
let pos = 0;
|
| 315 |
+
for (const a of list) {
|
| 316 |
+
const start = Math.max(0, Math.min(a.start, text.length));
|
| 317 |
+
const end = Math.max(start, Math.min(a.end, text.length));
|
| 318 |
+
if (pos < start) html += escapeHtml(text.slice(pos, start));
|
| 319 |
+
const label = CATEGORY_LABELS[a.category] || 'Note';
|
| 320 |
+
const cls = CATEGORY_CLASS[a.category] || 'bg-gray-100 ring-gray-200';
|
| 321 |
+
const span = escapeHtml(text.slice(start, end)) || ' ';
|
| 322 |
+
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>`;
|
| 323 |
+
pos = end;
|
| 324 |
+
}
|
| 325 |
+
if (pos < text.length) html += escapeHtml(text.slice(pos));
|
| 326 |
+
return html;
|
| 327 |
+
}
|
| 328 |
+
function rangeToOffsets(container: HTMLElement, range: Range, fullText: string): { start: number; end: number } {
|
| 329 |
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
|
| 330 |
+
let start = -1;
|
| 331 |
+
let end = -1;
|
| 332 |
+
let charCount = 0;
|
| 333 |
+
while (walker.nextNode()) {
|
| 334 |
+
const node = walker.currentNode as Text;
|
| 335 |
+
const text = node.nodeValue || '';
|
| 336 |
+
if (node === range.startContainer) start = charCount + range.startOffset;
|
| 337 |
+
if (node === range.endContainer) end = charCount + range.endOffset;
|
| 338 |
+
charCount += text.length;
|
| 339 |
+
}
|
| 340 |
+
if (start < 0 || end < 0) {
|
| 341 |
+
const sel = range.toString();
|
| 342 |
+
const idx = fullText.indexOf(sel);
|
| 343 |
+
if (idx >= 0) { start = idx; end = idx + sel.length; }
|
| 344 |
+
}
|
| 345 |
+
start = Math.max(0, Math.min(start, fullText.length));
|
| 346 |
+
end = Math.max(start, Math.min(end, fullText.length));
|
| 347 |
+
return { start, end };
|
| 348 |
+
}
|
| 349 |
+
function getSelectionPopoverPosition(range: Range, container: HTMLElement): { left: number; top: number } {
|
| 350 |
+
const rect = range.getBoundingClientRect();
|
| 351 |
+
const crect = container.getBoundingClientRect();
|
| 352 |
+
const left = Math.max(8, rect.right - crect.left + 8);
|
| 353 |
+
const top = Math.max(8, rect.top - crect.top - 8);
|
| 354 |
+
return { left, top };
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
// Flow slide text overflow measurement for multi-line ellipsis indicator
|
| 358 |
const textRefs = React.useRef<{ [id: string]: HTMLDivElement | null }>({});
|
| 359 |
const [overflowMap, setOverflowMap] = React.useState<{ [id: string]: boolean }>({});
|
|
|
|
| 527 |
<button onClick={() => setIsFullscreen(false)} className="px-3 py-1.5 text-sm rounded-2xl border border-gray-200 bg-white">Exit Full Screen</button>
|
| 528 |
</div>
|
| 529 |
)}
|
| 530 |
+
{/* Annotation modal (Version Flow only) */}
|
| 531 |
+
{annotationModalOpen && annotationPopover && (
|
| 532 |
+
<div className="fixed inset-0 z-[5000] flex items-center justify-center bg-black/40">
|
| 533 |
+
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
|
| 534 |
+
<h3 className="text-lg font-semibold text-gray-900 mb-4">{editingAnnotation ? 'Edit annotation' : 'New annotation'}</h3>
|
| 535 |
+
<div className="space-y-4">
|
| 536 |
+
<div>
|
| 537 |
+
<label className="block text-sm text-gray-700 mb-1">Category <span className="text-red-500">*</span></label>
|
| 538 |
+
<select
|
| 539 |
+
value={modalCategory}
|
| 540 |
+
onChange={(e)=>setModalCategory(e.target.value as AnnotationCategory)}
|
| 541 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm"
|
| 542 |
+
>
|
| 543 |
+
{(['distortion','omission','register','unidiomatic','grammar','spelling','punctuation','addition','other'] as AnnotationCategory[]).map(k=>(
|
| 544 |
+
<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>
|
| 545 |
+
))}
|
| 546 |
+
</select>
|
| 547 |
+
</div>
|
| 548 |
+
<div>
|
| 549 |
+
<label className="block text-sm text-gray-700 mb-1">Comment (optional)</label>
|
| 550 |
+
<textarea
|
| 551 |
+
value={modalComment}
|
| 552 |
+
onChange={(e)=>setModalComment(e.target.value)}
|
| 553 |
+
rows={3}
|
| 554 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm"
|
| 555 |
+
placeholder="Enter the correction or context…"
|
| 556 |
+
/>
|
| 557 |
+
</div>
|
| 558 |
+
</div>
|
| 559 |
+
<div className="mt-6 flex items-center justify-between">
|
| 560 |
+
{editingAnnotation && (
|
| 561 |
+
<button
|
| 562 |
+
onClick={()=>{
|
| 563 |
+
if (editingAnnotation) deleteAnnotationById(editingAnnotation.id);
|
| 564 |
+
setAnnotationModalOpen(false);
|
| 565 |
+
setAnnotationPopover(null);
|
| 566 |
+
setEditingAnnotation(null);
|
| 567 |
+
}}
|
| 568 |
+
className="px-3 py-1.5 text-sm rounded-lg text-white bg-red-600 hover:bg-red-700"
|
| 569 |
+
>
|
| 570 |
+
Delete
|
| 571 |
+
</button>
|
| 572 |
+
)}
|
| 573 |
+
<div className="ml-auto flex gap-2">
|
| 574 |
+
<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>
|
| 575 |
+
<button
|
| 576 |
+
onClick={()=>{
|
| 577 |
+
const versionId = annotationPopover.versionId;
|
| 578 |
+
if (editingAnnotation) {
|
| 579 |
+
const updated: Annotation = { ...editingAnnotation, category: modalCategory, comment: modalComment, updatedAt: Date.now() };
|
| 580 |
+
updateAnnotation(updated);
|
| 581 |
+
} else {
|
| 582 |
+
const range = annotationPopover.range;
|
| 583 |
+
const a: Annotation = {
|
| 584 |
+
id: `a_${Date.now()}_${Math.random().toString(36).slice(2,7)}`,
|
| 585 |
+
versionId,
|
| 586 |
+
start: range.start,
|
| 587 |
+
end: range.end,
|
| 588 |
+
category: modalCategory,
|
| 589 |
+
comment: modalComment,
|
| 590 |
+
createdAt: Date.now(),
|
| 591 |
+
updatedAt: Date.now(),
|
| 592 |
+
};
|
| 593 |
+
addAnnotation(a);
|
| 594 |
+
}
|
| 595 |
+
setAnnotationModalOpen(false);
|
| 596 |
+
setAnnotationPopover(null);
|
| 597 |
+
setEditingAnnotation(null);
|
| 598 |
+
}}
|
| 599 |
+
className="px-3 py-1.5 text-sm rounded-lg text-white bg-indigo-600 hover:bg-indigo-700"
|
| 600 |
+
>
|
| 601 |
+
Save
|
| 602 |
+
</button>
|
| 603 |
+
</div>
|
| 604 |
+
</div>
|
| 605 |
+
</div>
|
| 606 |
+
</div>
|
| 607 |
+
)}
|
| 608 |
{/* Clean container (no purple gradient) */}
|
| 609 |
<div className={isFullscreen ? 'relative mx-auto max-w-6xl p-6' : 'relative rounded-xl p-6 bg-transparent'}>
|
| 610 |
{/* Stage 1: Task Creation / Selection */}
|
|
|
|
| 826 |
ref={(el)=>{ textRefs.current[v.id]=el; }}
|
| 827 |
className={`text-gray-900 whitespace-pre-wrap break-words leading-relaxed flex-1 overflow-hidden pr-1 relative`}
|
| 828 |
style={{ maxHeight: 'calc(100% - 10.5rem)', paddingBottom: '0.25rem' }}
|
| 829 |
+
onMouseUp={(e)=>{
|
| 830 |
+
if (!showAnnotations) return;
|
| 831 |
+
const el = textRefs.current[v.id];
|
| 832 |
+
if (!el) return;
|
| 833 |
+
const sel = window.getSelection();
|
| 834 |
+
if (!sel || sel.rangeCount===0) return;
|
| 835 |
+
const range = sel.getRangeAt(0);
|
| 836 |
+
if (!el.contains(range.startContainer) || !el.contains(range.endContainer)) return;
|
| 837 |
+
const text = v.content || '';
|
| 838 |
+
const offsets = rangeToOffsets(el, range, text);
|
| 839 |
+
if (offsets.end - offsets.start <= 0) {
|
| 840 |
+
// caret: open if inside an existing annotation
|
| 841 |
+
const inside = (annoForVersion(v.id) || []).find(a => offsets.start >= a.start && offsets.start <= a.end);
|
| 842 |
+
if (inside) {
|
| 843 |
+
setEditingAnnotation(inside);
|
| 844 |
+
setModalCategory(inside.category);
|
| 845 |
+
setModalComment(inside.comment || '');
|
| 846 |
+
setAnnotationModalOpen(true);
|
| 847 |
+
}
|
| 848 |
+
setAnnotationPopover(null);
|
| 849 |
+
return;
|
| 850 |
+
}
|
| 851 |
+
const pos = getSelectionPopoverPosition(range, el);
|
| 852 |
+
setAnnotationPopover({ left: pos.left, top: pos.top, versionId: v.id, range: offsets });
|
| 853 |
+
}}
|
| 854 |
+
onClick={(e)=>{
|
| 855 |
+
if (!showAnnotations) return;
|
| 856 |
+
const target = e.target as HTMLElement;
|
| 857 |
+
const id = target?.dataset?.annoId;
|
| 858 |
+
if (id) {
|
| 859 |
+
e.preventDefault(); e.stopPropagation();
|
| 860 |
+
const found = (annoForVersion(v.id) || []).find(a => a.id === id);
|
| 861 |
+
if (found) {
|
| 862 |
+
setEditingAnnotation(found);
|
| 863 |
+
setModalCategory(found.category);
|
| 864 |
+
setModalComment(found.comment || '');
|
| 865 |
+
setAnnotationModalOpen(true);
|
| 866 |
+
}
|
| 867 |
+
}
|
| 868 |
+
}}
|
| 869 |
+
dangerouslySetInnerHTML={{ __html: renderAnnotatedHtml(v.content || '', v.id, showAnnotations) }}
|
| 870 |
+
/>
|
| 871 |
+
{annotationPopover && annotationPopover.versionId===v.id && (
|
| 872 |
+
<div className="absolute z-[3500]" style={{ left: annotationPopover.left, top: annotationPopover.top }}>
|
| 873 |
+
<button
|
| 874 |
+
type="button"
|
| 875 |
+
onClick={(e)=>{
|
| 876 |
+
e.preventDefault(); e.stopPropagation();
|
| 877 |
+
setEditingAnnotation(null);
|
| 878 |
+
setModalCategory('distortion');
|
| 879 |
+
setModalComment('');
|
| 880 |
+
setAnnotationModalOpen(true);
|
| 881 |
+
}}
|
| 882 |
+
className="inline-flex items-center gap-2 px-2.5 py-1.5 text-xs rounded-full text-white bg-emerald-600 hover:bg-emerald-700 shadow ring-1 ring-white/50"
|
| 883 |
+
>
|
| 884 |
+
+ Comment
|
| 885 |
+
</button>
|
| 886 |
+
</div>
|
| 887 |
+
)}
|
| 888 |
{overflowMap[v.id] && (
|
| 889 |
<div className="pointer-events-none absolute text-gray-700" style={{ left: '1.5rem', bottom: '6rem', zIndex: 2000 }}>…</div>
|
| 890 |
)}
|
|
|
|
| 1169 |
nextVersionNumber={((versions.filter(v=>v.taskId===(task?.id||'')).slice(-1)[0]?.versionNumber) || 0) + 1}
|
| 1170 |
isFullscreen={isFullscreen}
|
| 1171 |
onToggleFullscreen={()=>setIsFullscreen(v=>!v)}
|
| 1172 |
+
versionId={currentVersionId || ''}
|
| 1173 |
/>
|
| 1174 |
)}
|
| 1175 |
</div>
|
|
|
|
| 1177 |
);
|
| 1178 |
};
|
| 1179 |
|
| 1180 |
+
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 }>=({ source, initialTranslation, onBack, onSave, onSaveEdit, taskTitle, username, nextVersionNumber, isFullscreen, onToggleFullscreen, versionId })=>{
|
| 1181 |
const [text, setText] = React.useState<string>(initialTranslation);
|
| 1182 |
const [saving, setSaving] = React.useState(false);
|
| 1183 |
const [diffHtml, setDiffHtml] = React.useState<string>('');
|
|
|
|
| 1190 |
const [commentsOpen, setCommentsOpen] = React.useState(false);
|
| 1191 |
const [newComment, setNewComment] = React.useState('');
|
| 1192 |
const [comments, setComments] = React.useState<Array<{ id: string; text: string; ts: number }>>([]);
|
| 1193 |
+
const taRef = React.useRef<HTMLTextAreaElement | null>(null);
|
| 1194 |
+
const overlayRef = React.useRef<HTMLDivElement | null>(null);
|
| 1195 |
+
const wrapperRef = React.useRef<HTMLDivElement | null>(null);
|
| 1196 |
+
// Annotation layer state (revision mode only)
|
| 1197 |
+
const [showAnnotations, setShowAnnotations] = React.useState<boolean>(true);
|
| 1198 |
+
const ANNO_STORE_KEY = 'refinity_annotations_v1';
|
| 1199 |
+
type Ann = Annotation;
|
| 1200 |
+
const loadAnnotations = React.useCallback((): Ann[] => {
|
| 1201 |
+
try { const raw = localStorage.getItem(ANNO_STORE_KEY); const arr = raw ? JSON.parse(raw) : []; return Array.isArray(arr) ? arr : []; } catch { return []; }
|
| 1202 |
+
}, []);
|
| 1203 |
+
const saveAnnotations = React.useCallback((list: Ann[]) => { try { localStorage.setItem(ANNO_STORE_KEY, JSON.stringify(list)); } catch {} }, []);
|
| 1204 |
+
const [annotations, setAnnotations] = React.useState<Ann[]>(() => loadAnnotations());
|
| 1205 |
+
React.useEffect(() => { saveAnnotations(annotations); }, [annotations, saveAnnotations]);
|
| 1206 |
+
const versionAnnotations = React.useMemo(() => annotations.filter(a => a.versionId === versionId), [annotations, versionId]);
|
| 1207 |
+
const addAnnotation = (a: Ann) => setAnnotations(prev => [...prev, a]);
|
| 1208 |
+
const updateAnnotation = (a: Ann) => setAnnotations(prev => prev.map(x => x.id === a.id ? a : x));
|
| 1209 |
+
const deleteAnnotationById = (id: string) => setAnnotations(prev => prev.filter(a => a.id !== id));
|
| 1210 |
+
const CATEGORY_CLASS: Record<AnnotationCategory, string> = {
|
| 1211 |
+
distortion: 'bg-rose-100 ring-rose-200',
|
| 1212 |
+
omission: 'bg-amber-100 ring-amber-200',
|
| 1213 |
+
register: 'bg-indigo-100 ring-indigo-200',
|
| 1214 |
+
unidiomatic: 'bg-purple-100 ring-purple-200',
|
| 1215 |
+
grammar: 'bg-blue-100 ring-blue-200',
|
| 1216 |
+
spelling: 'bg-green-100 ring-green-200',
|
| 1217 |
+
punctuation: 'bg-teal-100 ring-teal-200',
|
| 1218 |
+
addition: 'bg-pink-100 ring-pink-200',
|
| 1219 |
+
other: 'bg-gray-100 ring-gray-200',
|
| 1220 |
+
};
|
| 1221 |
+
const [modalOpen, setModalOpen] = React.useState(false);
|
| 1222 |
+
const [editingAnn, setEditingAnn] = React.useState<Ann | null>(null);
|
| 1223 |
+
const [modalCategory, setModalCategory] = React.useState<AnnotationCategory>('distortion');
|
| 1224 |
+
const [modalComment, setModalComment] = React.useState('');
|
| 1225 |
+
const [popover, setPopover] = React.useState<{ left: number; top: number; start: number; end: number } | null>(null);
|
| 1226 |
+
|
| 1227 |
+
function escapeHtml(s: string): string {
|
| 1228 |
+
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
| 1229 |
+
}
|
| 1230 |
+
// Load persisted annotations for this version from backend
|
| 1231 |
+
React.useEffect(() => {
|
| 1232 |
+
(async () => {
|
| 1233 |
+
if (!versionId) return;
|
| 1234 |
+
try {
|
| 1235 |
+
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
|
| 1236 |
+
const resp = await fetch(`${base}/api/refinity/annotations?versionId=${encodeURIComponent(versionId)}`);
|
| 1237 |
+
const rows = await resp.json().catch(()=>[]);
|
| 1238 |
+
if (Array.isArray(rows)) {
|
| 1239 |
+
setAnnotations(prev => {
|
| 1240 |
+
const others = prev.filter(a => a.versionId !== versionId);
|
| 1241 |
+
const loaded = rows.map((r:any)=>({ id: r._id, versionId: r.versionId, start: r.start, end: r.end, category: r.category, comment: r.comment, createdAt: r.createdAt, updatedAt: r.updatedAt }));
|
| 1242 |
+
return [...others, ...loaded];
|
| 1243 |
+
});
|
| 1244 |
+
}
|
| 1245 |
+
} catch {}
|
| 1246 |
+
})();
|
| 1247 |
+
}, [versionId]);
|
| 1248 |
+
|
| 1249 |
+
// Persistence helpers
|
| 1250 |
+
const persistCreate = React.useCallback(async (a: Ann): Promise<Ann> => {
|
| 1251 |
+
try {
|
| 1252 |
+
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
|
| 1253 |
+
const body = { versionId: a.versionId, start: a.start, end: a.end, category: a.category, comment: a.comment };
|
| 1254 |
+
const resp = await fetch(`${base}/api/refinity/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
| 1255 |
+
const row = await resp.json().catch(()=>({}));
|
| 1256 |
+
if (resp.ok && row && row._id) return { ...a, id: row._id };
|
| 1257 |
+
} catch {}
|
| 1258 |
+
return a;
|
| 1259 |
+
}, []);
|
| 1260 |
+
const persistUpdate = React.useCallback(async (a: Ann) => {
|
| 1261 |
+
try {
|
| 1262 |
+
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
|
| 1263 |
+
const body = { start: a.start, end: a.end, category: a.category, comment: a.comment };
|
| 1264 |
+
await fetch(`${base}/api/refinity/annotations/${encodeURIComponent(a.id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
| 1265 |
+
} catch {}
|
| 1266 |
+
}, []);
|
| 1267 |
+
const persistDelete = React.useCallback(async (id: string) => {
|
| 1268 |
+
try {
|
| 1269 |
+
const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
|
| 1270 |
+
await fetch(`${base}/api/refinity/annotations/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
| 1271 |
+
} catch {}
|
| 1272 |
+
}, []);
|
| 1273 |
+
function renderAnnotatedHtml(textValue: string): string {
|
| 1274 |
+
if (!showAnnotations) return escapeHtml(textValue);
|
| 1275 |
+
const list = versionAnnotations.slice().sort((a,b)=>a.start-b.start);
|
| 1276 |
+
if (!list.length) return escapeHtml(textValue);
|
| 1277 |
+
let html = ''; let pos = 0;
|
| 1278 |
+
for (const a of list) {
|
| 1279 |
+
const start = Math.max(0, Math.min(a.start, textValue.length));
|
| 1280 |
+
const end = Math.max(start, Math.min(a.end, textValue.length));
|
| 1281 |
+
if (pos < start) html += escapeHtml(textValue.slice(pos, start));
|
| 1282 |
+
const cls = CATEGORY_CLASS[a.category] || 'bg-gray-100';
|
| 1283 |
+
const span = escapeHtml(textValue.slice(start, end)) || ' ';
|
| 1284 |
+
html += `<span data-anno-id="${a.id}" class="inline rounded-[4px] ${cls} pointer-events-auto cursor-pointer" title="${a.category}${a.comment ? ': '+escapeHtml(a.comment) : ''}">${span}</span>`;
|
| 1285 |
+
pos = end;
|
| 1286 |
+
}
|
| 1287 |
+
if (pos < textValue.length) html += escapeHtml(textValue.slice(pos));
|
| 1288 |
+
return html;
|
| 1289 |
+
}
|
| 1290 |
+
function offsetsToClientRect(container: HTMLElement, start: number, end: number): DOMRect | null {
|
| 1291 |
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
|
| 1292 |
+
let charCount = 0;
|
| 1293 |
+
let startNode: Text | null = null, endNode: Text | null = null;
|
| 1294 |
+
let startOffset = 0, endOffset = 0;
|
| 1295 |
+
while (walker.nextNode()) {
|
| 1296 |
+
const node = walker.currentNode as Text;
|
| 1297 |
+
const len = (node.nodeValue || '').length;
|
| 1298 |
+
if (!startNode && charCount + len >= start) { startNode = node; startOffset = start - charCount; }
|
| 1299 |
+
if (!endNode && charCount + len >= end) { endNode = node; endOffset = end - charCount; break; }
|
| 1300 |
+
charCount += len;
|
| 1301 |
+
}
|
| 1302 |
+
if (!startNode || !endNode) return null;
|
| 1303 |
+
const r = document.createRange();
|
| 1304 |
+
r.setStart(startNode, Math.max(0, Math.min(startOffset, (startNode.nodeValue||'').length)));
|
| 1305 |
+
r.setEnd(endNode, Math.max(0, Math.min(endOffset, (endNode.nodeValue||'').length)));
|
| 1306 |
+
const rect = r.getBoundingClientRect();
|
| 1307 |
+
return rect;
|
| 1308 |
+
}
|
| 1309 |
+
const updateSelectionPopover = React.useCallback(() => {
|
| 1310 |
+
const ta = taRef.current, ov = overlayRef.current, wrap = wrapperRef.current;
|
| 1311 |
+
if (!ta || !ov || !wrap) { setPopover(null); return; }
|
| 1312 |
+
const s = ta.selectionStart ?? 0, e = ta.selectionEnd ?? 0;
|
| 1313 |
+
if (e - s <= 0) { setPopover(null); return; }
|
| 1314 |
+
const rect = offsetsToClientRect(ov, s, e);
|
| 1315 |
+
if (!rect) { setPopover(null); return; }
|
| 1316 |
+
const wrect = wrap.getBoundingClientRect();
|
| 1317 |
+
setPopover({ left: Math.max(8, rect.right - wrect.left + 8), top: Math.max(8, rect.top - wrect.top - 8), start: s, end: e });
|
| 1318 |
+
}, [showAnnotations]);
|
| 1319 |
|
| 1320 |
React.useEffect(() => {
|
| 1321 |
if (sourceRef.current) {
|
|
|
|
| 1408 |
<div className="w-1/2">
|
| 1409 |
<div className="mb-2 text-gray-700 text-sm flex items-center justify-between">
|
| 1410 |
<span>Translation</span>
|
| 1411 |
+
<div className="flex items-center gap-2">
|
| 1412 |
+
<label className="hidden md:flex items-center gap-2 text-xs text-gray-800 ring-1 ring-gray-200 rounded-xl px-2 py-0.5 bg-white/50">
|
| 1413 |
+
<input type="checkbox" checked={showAnnotations} onChange={(e)=>{ setShowAnnotations(e.target.checked); setPopover(null); }} />
|
| 1414 |
+
<span>Show comments</span>
|
| 1415 |
+
</label>
|
| 1416 |
+
{!isFullscreen && (
|
| 1417 |
+
<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">
|
| 1418 |
+
Full Screen
|
| 1419 |
+
</button>
|
| 1420 |
+
)}
|
| 1421 |
+
</div>
|
| 1422 |
+
</div>
|
| 1423 |
+
<div ref={wrapperRef} className="relative overflow-hidden">
|
| 1424 |
+
{/* Overlay highlights rendered above, but non-interactive; textarea handles selection */}
|
| 1425 |
+
<div
|
| 1426 |
+
ref={overlayRef}
|
| 1427 |
+
className="pointer-events-none absolute inset-0 rounded-lg px-4 py-3 whitespace-pre-wrap text-gray-900 overflow-hidden z-0 text-base leading-relaxed will-change-transform"
|
| 1428 |
+
style={{ minHeight: textareaHeight, height: textareaHeight, maxHeight: textareaHeight, transform: 'translateY(0px)' }}
|
| 1429 |
+
dangerouslySetInnerHTML={{ __html: renderAnnotatedHtml(text) }}
|
| 1430 |
+
/>
|
| 1431 |
+
<textarea
|
| 1432 |
+
ref={taRef}
|
| 1433 |
+
value={text}
|
| 1434 |
+
onChange={(e)=>setText(e.target.value)}
|
| 1435 |
+
onMouseUp={()=>{
|
| 1436 |
+
updateSelectionPopover();
|
| 1437 |
+
// If caret inside an existing annotation, open edit modal
|
| 1438 |
+
const s = taRef.current?.selectionStart ?? 0;
|
| 1439 |
+
const e = taRef.current?.selectionEnd ?? 0;
|
| 1440 |
+
if (s === e) {
|
| 1441 |
+
const hit = versionAnnotations.find(a => s >= a.start && s <= a.end);
|
| 1442 |
+
if (hit) {
|
| 1443 |
+
setEditingAnn(hit);
|
| 1444 |
+
setModalCategory(hit.category);
|
| 1445 |
+
setModalComment(hit.comment || '');
|
| 1446 |
+
setModalOpen(true);
|
| 1447 |
+
}
|
| 1448 |
+
}
|
| 1449 |
+
}}
|
| 1450 |
+
onScroll={(e)=>{ const el = e.currentTarget; if (overlayRef.current) { overlayRef.current.style.transform = `translateY(-${el.scrollTop}px)`; } }}
|
| 1451 |
+
onKeyUp={updateSelectionPopover}
|
| 1452 |
+
className="relative z-10 w-full px-4 py-3 border border-ui-border rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white text-base leading-relaxed"
|
| 1453 |
+
style={{
|
| 1454 |
+
minHeight: textareaHeight,
|
| 1455 |
+
height: textareaHeight,
|
| 1456 |
+
maxHeight: textareaHeight,
|
| 1457 |
+
resize: 'none',
|
| 1458 |
+
overflowY: 'auto',
|
| 1459 |
+
background: 'transparent',
|
| 1460 |
+
color: 'transparent' as any,
|
| 1461 |
+
caretColor: '#111827'
|
| 1462 |
+
}}
|
| 1463 |
+
/>
|
| 1464 |
+
{/* + Comment popover */}
|
| 1465 |
+
{popover && (
|
| 1466 |
+
<div className="absolute z-20" style={{ left: popover.left, top: popover.top }}>
|
| 1467 |
+
<button
|
| 1468 |
+
type="button"
|
| 1469 |
+
onClick={()=>{
|
| 1470 |
+
setEditingAnn(null);
|
| 1471 |
+
setModalCategory('distortion');
|
| 1472 |
+
setModalComment('');
|
| 1473 |
+
setModalOpen(true);
|
| 1474 |
+
}}
|
| 1475 |
+
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"
|
| 1476 |
+
>
|
| 1477 |
+
<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%)]" />
|
| 1478 |
+
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
|
| 1479 |
+
<span className="relative z-10">+ Comment</span>
|
| 1480 |
+
</button>
|
| 1481 |
+
</div>
|
| 1482 |
)}
|
| 1483 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1484 |
<div className="mt-4 flex gap-3 relative">
|
| 1485 |
<button onClick={save} disabled={saving} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-indigo-600/70 disabled:bg-gray-400 active:translate-y-0.5 transition-all duration-200">{saving? 'Saving…':'Save'}</button>
|
| 1486 |
<div className="relative inline-block align-top">
|
| 1487 |
+
<button ref={revBtnRef} onClick={(e)=>{ e.preventDefault(); e.stopPropagation(); const r=(e.currentTarget as HTMLElement).getBoundingClientRect(); setRevMenuPos({ left: r.left, top: r.bottom + 8 }); 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>
|
| 1488 |
+
{revDownloadOpen && revMenuPos && createPortal(
|
| 1489 |
+
<div style={{ position: 'fixed', left: revMenuPos.left, top: revMenuPos.top, zIndex: 10000 }} className="w-56 rounded-md border border-gray-200 bg-white shadow-lg text-left">
|
| 1490 |
+
<button onClick={async()=>{ setRevDownloadOpen(false); try { const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const filename=`${(taskTitle||'Task').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_${(username||'User').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}.docx`; const body={ current: text||'', filename }; const resp=await fetch(`${base}/api/refinity/export-plain`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body) }); if(!resp.ok) throw new Error('Export failed'); const blob=await resp.blob(); const url=window.URL.createObjectURL(blob); const link=document.createElement('a'); link.href=url; link.download=filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);} catch {} }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Without Annotations</button>
|
| 1491 |
+
<button onClick={async()=>{ setRevDownloadOpen(false); try { const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const filename=`${(taskTitle||'Task').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_${(username||'User').replace(/[^\w\-\s]/g,'').replace(/\s+/g,'_')}_annotated.docx`; const list=versionAnnotations.map(a=>({ start:a.start, end:a.end, category:a.category, comment:a.comment })); const body={ current: text||'', filename, annotations: list }; const resp=await fetch(`${base}/api/refinity/export-plain-with-annotations`,{ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body) }); if(!resp.ok) throw new Error('Export failed'); const blob=await resp.blob(); const url=window.URL.createObjectURL(blob); const link=document.createElement('a'); link.href=url; link.download=filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url);} catch {} }} className="block w-full text-left px-3 py-2 text-sm hover:bg-gray-50">With Annotations</button>
|
| 1492 |
+
</div>, document.body
|
| 1493 |
+
)}
|
| 1494 |
</div>
|
| 1495 |
+
{/* Comments button removed per request */}
|
| 1496 |
<button onClick={onBack} className="ml-auto relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-black ring-1 ring-inset ring-white/50 backdrop-blur-md bg-white/30 active:translate-y-0.5 transition-all duration-200">Back</button>
|
| 1497 |
</div>
|
| 1498 |
+
{/* Inline comments drawer removed */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1499 |
{showDiff && (
|
| 1500 |
<div className="mt-6 relative rounded-xl">
|
| 1501 |
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-indigo-200/45 via-indigo-100/40 to-indigo-300/45" />
|
|
|
|
| 1508 |
)}
|
| 1509 |
</div>
|
| 1510 |
</div>
|
| 1511 |
+
{/* Annotation modal */}
|
| 1512 |
+
{modalOpen && (
|
| 1513 |
+
<div className="fixed inset-0 z-[5000] flex items-center justify-center bg-black/40">
|
| 1514 |
+
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
|
| 1515 |
+
<h3 className="text-lg font-semibold text-gray-900 mb-4">{editingAnn ? 'Edit annotation' : 'New annotation'}</h3>
|
| 1516 |
+
<div className="space-y-4">
|
| 1517 |
+
<div>
|
| 1518 |
+
<label className="block text-sm text-gray-700 mb-1">Category <span className="text-red-500">*</span></label>
|
| 1519 |
+
<select
|
| 1520 |
+
value={modalCategory}
|
| 1521 |
+
onChange={(e)=>setModalCategory(e.target.value as AnnotationCategory)}
|
| 1522 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm"
|
| 1523 |
+
>
|
| 1524 |
+
<option value="distortion">Distortion</option>
|
| 1525 |
+
<option value="omission">Unjustified omission</option>
|
| 1526 |
+
<option value="register">Inappropriate register</option>
|
| 1527 |
+
<option value="unidiomatic">Unidiomatic expression</option>
|
| 1528 |
+
<option value="grammar">Error of grammar, syntax</option>
|
| 1529 |
+
<option value="spelling">Error of spelling</option>
|
| 1530 |
+
<option value="punctuation">Error of punctuation</option>
|
| 1531 |
+
<option value="addition">Unjustified addition</option>
|
| 1532 |
+
<option value="other">Other</option>
|
| 1533 |
+
</select>
|
| 1534 |
+
</div>
|
| 1535 |
+
<div>
|
| 1536 |
+
<label className="block text-sm text-gray-700 mb-1">Comment (optional)</label>
|
| 1537 |
+
<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…" />
|
| 1538 |
+
</div>
|
| 1539 |
+
</div>
|
| 1540 |
+
<div className="mt-6 flex items-center justify-between">
|
| 1541 |
+
{editingAnn && (
|
| 1542 |
+
<button onClick={async()=>{ await persistDelete(editingAnn.id); deleteAnnotationById(editingAnn.id); setModalOpen(false); setPopover(null); setEditingAnn(null); }} className="px-3 py-1.5 text-sm rounded-lg text-white bg-red-600 hover:bg-red-700">Delete</button>
|
| 1543 |
+
)}
|
| 1544 |
+
<div className="ml-auto flex gap-2">
|
| 1545 |
+
<button onClick={()=>{ setModalOpen(false); setPopover(null); setEditingAnn(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>
|
| 1546 |
+
<button
|
| 1547 |
+
onClick={async ()=>{
|
| 1548 |
+
if (editingAnn) {
|
| 1549 |
+
const updated = { ...editingAnn, category: modalCategory, comment: modalComment, updatedAt: Date.now() };
|
| 1550 |
+
updateAnnotation(updated);
|
| 1551 |
+
await persistUpdate(updated);
|
| 1552 |
+
} else if (popover) {
|
| 1553 |
+
const local = { id:`a_${Date.now()}_${Math.random().toString(36).slice(2,7)}`, versionId, start: popover.start, end: popover.end, category: modalCategory, comment: modalComment, createdAt: Date.now(), updatedAt: Date.now() };
|
| 1554 |
+
const saved = await persistCreate(local);
|
| 1555 |
+
addAnnotation(saved);
|
| 1556 |
+
}
|
| 1557 |
+
setModalOpen(false); setPopover(null); setEditingAnn(null);
|
| 1558 |
+
}}
|
| 1559 |
+
className="px-3 py-1.5 text-sm rounded-lg text-white bg-indigo-600 hover:bg-indigo-700"
|
| 1560 |
+
>
|
| 1561 |
+
Save
|
| 1562 |
+
</button>
|
| 1563 |
+
</div>
|
| 1564 |
+
</div>
|
| 1565 |
+
</div>
|
| 1566 |
+
</div>
|
| 1567 |
+
)}
|
| 1568 |
</div>
|
| 1569 |
);
|
| 1570 |
};
|
client/src/pages/Toolkit.tsx
CHANGED
|
@@ -374,9 +374,9 @@ const Toolkit: React.FC = () => {
|
|
| 374 |
{tools.map(t => {
|
| 375 |
const isActive = selectedTool === t.key;
|
| 376 |
return (
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
className={`relative inline-flex items-center justify-center rounded-2xl px-4 py-1.5 whitespace-nowrap transition-all duration-300 ease-out ring-1 ring-inset ${isActive ? 'ring-white/50 backdrop-brightness-110 backdrop-saturate-150' : 'ring-white/30'} backdrop-blur-md isolate shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)]`}
|
| 381 |
style={{ background: 'rgba(255,255,255,0.10)' }}
|
| 382 |
>
|
|
@@ -393,7 +393,7 @@ const Toolkit: React.FC = () => {
|
|
| 393 |
{/* Tint overlay: Indigo family to match Toolkit */}
|
| 394 |
<div className={`pointer-events-none absolute inset-0 rounded-2xl ${isActive ? 'bg-indigo-600/70 mix-blend-normal opacity-100' : 'bg-indigo-500/30 mix-blend-overlay opacity-35'}`} />
|
| 395 |
<span className={`relative z-10 text-sm font-medium ${isActive ? 'text-white' : 'text-black'}`}>{t.name}</span>
|
| 396 |
-
|
| 397 |
);
|
| 398 |
})}
|
| 399 |
</div>
|
|
|
|
| 374 |
{tools.map(t => {
|
| 375 |
const isActive = selectedTool === t.key;
|
| 376 |
return (
|
| 377 |
+
<button
|
| 378 |
+
key={t.key}
|
| 379 |
+
onClick={() => handleToolChange(t.key)}
|
| 380 |
className={`relative inline-flex items-center justify-center rounded-2xl px-4 py-1.5 whitespace-nowrap transition-all duration-300 ease-out ring-1 ring-inset ${isActive ? 'ring-white/50 backdrop-brightness-110 backdrop-saturate-150' : 'ring-white/30'} backdrop-blur-md isolate shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)]`}
|
| 381 |
style={{ background: 'rgba(255,255,255,0.10)' }}
|
| 382 |
>
|
|
|
|
| 393 |
{/* Tint overlay: Indigo family to match Toolkit */}
|
| 394 |
<div className={`pointer-events-none absolute inset-0 rounded-2xl ${isActive ? 'bg-indigo-600/70 mix-blend-normal opacity-100' : 'bg-indigo-500/30 mix-blend-overlay opacity-35'}`} />
|
| 395 |
<span className={`relative z-10 text-sm font-medium ${isActive ? 'text-white' : 'text-black'}`}>{t.name}</span>
|
| 396 |
+
</button>
|
| 397 |
);
|
| 398 |
})}
|
| 399 |
</div>
|
client/src/pages/TutorialTasks.tsx
CHANGED
|
@@ -957,8 +957,8 @@ const TutorialTasks: React.FC = () => {
|
|
| 957 |
console.log('[Trace] ScrollPreserve:before', { taskId, topBefore });
|
| 958 |
} catch {}
|
| 959 |
React.startTransition(() => {
|
| 960 |
-
|
| 961 |
-
|
| 962 |
});
|
| 963 |
if (!isSafari) {
|
| 964 |
// Narrow refetch immediately for non-Safari
|
|
@@ -970,7 +970,7 @@ const TutorialTasks: React.FC = () => {
|
|
| 970 |
}).catch(() => {
|
| 971 |
requestAnimationFrame(() => setSpacerHeights(prev => ({ ...prev, [taskId]: 0 })));
|
| 972 |
});
|
| 973 |
-
|
| 974 |
// Safari: delay refetch and keep locks until refetch completes
|
| 975 |
pendingUnlocksRef.current.add(taskId);
|
| 976 |
setTimeout(() => {
|
|
@@ -2468,8 +2468,8 @@ const TutorialTasks: React.FC = () => {
|
|
| 2468 |
</button>
|
| 2469 |
</div>
|
| 2470 |
<div ref={(el) => { submissionsGridRefs.current[task._id] = el; }} className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 ${
|
| 2471 |
-
expandedSections[task._id]
|
| 2472 |
-
? 'max-h-none overflow-visible'
|
| 2473 |
: 'max-h-0 overflow-hidden'
|
| 2474 |
}`} data-grid-id={task._id}>
|
| 2475 |
{userSubmissions[task._id].map((submission, index) => (
|
|
|
|
| 957 |
console.log('[Trace] ScrollPreserve:before', { taskId, topBefore });
|
| 958 |
} catch {}
|
| 959 |
React.startTransition(() => {
|
| 960 |
+
setTranslationText({ ...translationText, [taskId]: '' });
|
| 961 |
+
setSelectedGroups({ ...selectedGroups, [taskId]: 0 });
|
| 962 |
});
|
| 963 |
if (!isSafari) {
|
| 964 |
// Narrow refetch immediately for non-Safari
|
|
|
|
| 970 |
}).catch(() => {
|
| 971 |
requestAnimationFrame(() => setSpacerHeights(prev => ({ ...prev, [taskId]: 0 })));
|
| 972 |
});
|
| 973 |
+
} else {
|
| 974 |
// Safari: delay refetch and keep locks until refetch completes
|
| 975 |
pendingUnlocksRef.current.add(taskId);
|
| 976 |
setTimeout(() => {
|
|
|
|
| 2468 |
</button>
|
| 2469 |
</div>
|
| 2470 |
<div ref={(el) => { submissionsGridRefs.current[task._id] = el; }} className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 ${
|
| 2471 |
+
expandedSections[task._id]
|
| 2472 |
+
? 'max-h-none overflow-visible'
|
| 2473 |
: 'max-h-0 overflow-hidden'
|
| 2474 |
}`} data-grid-id={task._id}>
|
| 2475 |
{userSubmissions[task._id].map((submission, index) => (
|
client/src/pages/WeeklyPractice.tsx
CHANGED
|
@@ -1458,9 +1458,9 @@ const WeeklyPractice: React.FC = () => {
|
|
| 1458 |
{weeks.map((week) => {
|
| 1459 |
const isActive = selectedWeek === week;
|
| 1460 |
return (
|
| 1461 |
-
|
| 1462 |
-
|
| 1463 |
-
|
| 1464 |
className={`relative inline-flex items-center justify-center rounded-2xl px-4 py-1.5 whitespace-nowrap transition-all duration-300 ease-out ring-1 ring-inset ${isActive ? 'ring-white/50 backdrop-brightness-110 backdrop-saturate-150' : 'ring-white/30'} backdrop-blur-md isolate shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)]`}
|
| 1465 |
style={{ background: 'rgba(255,255,255,0.10)' }}
|
| 1466 |
>
|
|
@@ -1477,7 +1477,7 @@ const WeeklyPractice: React.FC = () => {
|
|
| 1477 |
{/* Pink tint overlay to match Weekly Practice identity */}
|
| 1478 |
<div className={`pointer-events-none absolute inset-0 rounded-2xl ${isActive ? 'bg-pink-600/70 mix-blend-normal opacity-100' : 'bg-pink-500/30 mix-blend-overlay opacity-35'}`} />
|
| 1479 |
<span className={`relative z-10 text-sm font-medium ${isActive ? 'text-white' : 'text-black'}`}>Week {week}</span>
|
| 1480 |
-
|
| 1481 |
);
|
| 1482 |
})}
|
| 1483 |
</div>
|
|
@@ -1752,204 +1752,204 @@ const WeeklyPractice: React.FC = () => {
|
|
| 1752 |
</div>
|
| 1753 |
{((localStorage.getItem('viewMode')||'auto') !== 'student' && (JSON.parse(localStorage.getItem('user')||'{}').role === 'admin') && showSubmissions) && (
|
| 1754 |
<>
|
| 1755 |
-
|
| 1756 |
-
|
| 1757 |
-
|
| 1758 |
-
</div>
|
| 1759 |
-
|
| 1760 |
-
{/* Video Player */}
|
| 1761 |
-
<div className="bg-black rounded-lg aspect-video overflow-hidden relative">
|
| 1762 |
-
<video
|
| 1763 |
-
className="w-full h-full"
|
| 1764 |
-
controls
|
| 1765 |
-
preload="metadata"
|
| 1766 |
-
id="nike-video"
|
| 1767 |
-
onError={(e) => console.error('Video loading error:', e)}
|
| 1768 |
-
onLoadStart={() => console.log('Video loading started')}
|
| 1769 |
-
onCanPlay={() => console.log('Video can play')}
|
| 1770 |
-
>
|
| 1771 |
-
<source src="https://huggingface.co/spaces/linguabot/transcreation-frontend/resolve/main/public/videos/nike-winning-isnt-for-everyone.mp4" type="video/mp4" />
|
| 1772 |
-
Your browser does not support the video tag.
|
| 1773 |
-
</video>
|
| 1774 |
-
|
| 1775 |
-
{/* On-screen subtitles */}
|
| 1776 |
-
{currentDisplayedSubtitle && (
|
| 1777 |
-
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 z-10">
|
| 1778 |
-
<div className="bg-black bg-opacity-80 text-white px-6 py-3 rounded-lg text-center max-w-lg">
|
| 1779 |
-
<p className={`text-base font-medium leading-relaxed tracking-wide ${currentDisplayedSubtitle.length <= 42 ? 'whitespace-nowrap' : 'whitespace-pre-line'}`} style={{ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }}>
|
| 1780 |
-
{formatSubtitleForDisplay(currentDisplayedSubtitle)}
|
| 1781 |
-
</p>
|
| 1782 |
-
</div>
|
| 1783 |
-
</div>
|
| 1784 |
-
)}
|
| 1785 |
-
</div>
|
| 1786 |
</div>
|
| 1787 |
-
|
| 1788 |
-
{/*
|
| 1789 |
-
<div className="bg-
|
| 1790 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1791 |
|
| 1792 |
-
{/*
|
| 1793 |
-
{
|
| 1794 |
-
<div className="
|
| 1795 |
-
|
| 1796 |
-
<
|
| 1797 |
-
{
|
| 1798 |
-
</
|
| 1799 |
-
|
| 1800 |
-
</div>
|
| 1801 |
-
) : (
|
| 1802 |
-
<div className="grid grid-cols-9 gap-1 mb-6">
|
| 1803 |
-
{subtitleSegments.map((segment) => (
|
| 1804 |
-
<button
|
| 1805 |
-
key={segment.id}
|
| 1806 |
-
onClick={() => handleSegmentClick(segment.id)}
|
| 1807 |
-
className={`px-1 py-1 rounded text-xs w-full ${getSegmentButtonClass(segment.id)}`}
|
| 1808 |
-
>
|
| 1809 |
-
{segment.id}
|
| 1810 |
-
</button>
|
| 1811 |
-
))}
|
| 1812 |
</div>
|
| 1813 |
)}
|
| 1814 |
-
|
| 1815 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1816 |
{editingSegment && (((localStorage.getItem('viewMode')||'auto') === 'student') ? false : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin')) && (
|
| 1817 |
-
|
| 1818 |
-
|
| 1819 |
-
|
| 1820 |
-
|
| 1821 |
-
|
| 1822 |
-
|
| 1823 |
-
|
| 1824 |
-
|
| 1825 |
-
|
| 1826 |
-
|
| 1827 |
-
|
| 1828 |
-
|
| 1829 |
-
</div>
|
| 1830 |
-
<div>
|
| 1831 |
-
<label className="block text-xs text-indigo-700 mb-1">End Time</label>
|
| 1832 |
-
<input
|
| 1833 |
-
type="text"
|
| 1834 |
-
id="endTimeInput"
|
| 1835 |
-
defaultValue={subtitleSegments[editingSegment - 1]?.endTime}
|
| 1836 |
-
className="w-full px-2 py-1 text-xs border border-indigo-300 rounded"
|
| 1837 |
-
placeholder="00:00:00,000"
|
| 1838 |
-
/>
|
| 1839 |
-
</div>
|
| 1840 |
</div>
|
| 1841 |
-
<div
|
| 1842 |
-
<
|
| 1843 |
-
|
| 1844 |
-
|
| 1845 |
-
|
| 1846 |
-
|
| 1847 |
-
|
| 1848 |
-
|
| 1849 |
-
|
| 1850 |
-
const startInput = document.getElementById('startTimeInput') as HTMLInputElement;
|
| 1851 |
-
const endInput = document.getElementById('endTimeInput') as HTMLInputElement;
|
| 1852 |
-
if (startInput && endInput && startInput.value && endInput.value) {
|
| 1853 |
-
handleSaveTimeCode(editingSegment, startInput.value, endInput.value);
|
| 1854 |
-
}
|
| 1855 |
-
}}
|
| 1856 |
-
className="px-2 py-1 text-xs bg-indigo-600 text-white rounded"
|
| 1857 |
-
>
|
| 1858 |
-
Save
|
| 1859 |
-
</button>
|
| 1860 |
</div>
|
| 1861 |
</div>
|
| 1862 |
-
|
| 1863 |
-
|
| 1864 |
-
|
| 1865 |
-
|
| 1866 |
-
|
| 1867 |
-
|
| 1868 |
-
|
| 1869 |
-
<
|
| 1870 |
-
|
| 1871 |
-
|
| 1872 |
-
|
| 1873 |
-
|
| 1874 |
-
|
| 1875 |
-
|
| 1876 |
-
|
| 1877 |
-
|
| 1878 |
-
|
| 1879 |
-
|
|
|
|
| 1880 |
</div>
|
| 1881 |
</div>
|
| 1882 |
-
|
| 1883 |
-
|
| 1884 |
-
|
| 1885 |
-
|
| 1886 |
-
|
| 1887 |
-
|
| 1888 |
-
|
| 1889 |
-
|
| 1890 |
-
|
| 1891 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1892 |
</div>
|
| 1893 |
-
|
| 1894 |
-
|
| 1895 |
-
|
| 1896 |
-
|
| 1897 |
-
|
| 1898 |
-
|
| 1899 |
-
|
| 1900 |
-
|
| 1901 |
-
|
| 1902 |
-
|
| 1903 |
-
|
| 1904 |
-
|
| 1905 |
-
|
| 1906 |
-
|
| 1907 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1908 |
</div>
|
| 1909 |
-
|
| 1910 |
-
|
| 1911 |
-
|
| 1912 |
-
|
| 1913 |
-
|
| 1914 |
-
|
| 1915 |
-
|
| 1916 |
-
|
| 1917 |
-
|
| 1918 |
-
|
| 1919 |
-
|
| 1920 |
-
|
| 1921 |
-
|
| 1922 |
-
|
| 1923 |
-
|
| 1924 |
-
|
| 1925 |
-
|
| 1926 |
-
|
| 1927 |
-
|
| 1928 |
-
|
| 1929 |
-
|
| 1930 |
-
|
| 1931 |
-
|
| 1932 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1933 |
</div>
|
| 1934 |
-
|
| 1935 |
-
|
| 1936 |
-
|
| 1937 |
-
|
| 1938 |
-
|
| 1939 |
-
<span className="text-sm text-gray-600">{Object.keys(subtitleTranslations).length} of {subtitleSegments.length} segments completed</span>
|
| 1940 |
-
</div>
|
| 1941 |
-
<div className="w-full bg-gray-200 rounded-full h-2">
|
| 1942 |
-
<div
|
| 1943 |
-
className="bg-green-500 h-2 rounded-full transition-all duration-300"
|
| 1944 |
-
style={{ width: `${(Object.keys(subtitleTranslations).length / subtitleSegments.length) * 100}%` }}
|
| 1945 |
-
></div>
|
| 1946 |
-
</div>
|
| 1947 |
</div>
|
| 1948 |
</div>
|
| 1949 |
-
|
| 1950 |
-
|
| 1951 |
-
|
| 1952 |
-
|
|
|
|
| 1953 |
<div className="space-y-6">
|
| 1954 |
{/* Show Subtitling UI Button for Week 2 Admin */}
|
| 1955 |
{selectedWeek === 2 && (() => {
|
|
@@ -1959,8 +1959,8 @@ const WeeklyPractice: React.FC = () => {
|
|
| 1959 |
})() && (
|
| 1960 |
<div className="mb-4">
|
| 1961 |
<button onClick={() => setShowSubmissions(v=>!v)} className="px-3 py-1.5 text-sm rounded-md border border-gray-300">{showSubmissions ? 'Hide Subtitling UI' : 'Show Subtitling UI (Admin)'}</button>
|
| 1962 |
-
|
| 1963 |
-
|
| 1964 |
{/* Add Practice Button for Admin */}
|
| 1965 |
{(() => {
|
| 1966 |
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
|
@@ -2592,9 +2592,9 @@ const WeeklyPractice: React.FC = () => {
|
|
| 2592 |
)}
|
| 2593 |
</div>
|
| 2594 |
))}
|
| 2595 |
-
|
| 2596 |
-
|
| 2597 |
-
|
| 2598 |
|
| 2599 |
{/* Edit Submission Modal */}
|
| 2600 |
{editingSubmission && (
|
|
|
|
| 1458 |
{weeks.map((week) => {
|
| 1459 |
const isActive = selectedWeek === week;
|
| 1460 |
return (
|
| 1461 |
+
<button
|
| 1462 |
+
key={week}
|
| 1463 |
+
onClick={() => handleWeekChange(week)}
|
| 1464 |
className={`relative inline-flex items-center justify-center rounded-2xl px-4 py-1.5 whitespace-nowrap transition-all duration-300 ease-out ring-1 ring-inset ${isActive ? 'ring-white/50 backdrop-brightness-110 backdrop-saturate-150' : 'ring-white/30'} backdrop-blur-md isolate shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)]`}
|
| 1465 |
style={{ background: 'rgba(255,255,255,0.10)' }}
|
| 1466 |
>
|
|
|
|
| 1477 |
{/* Pink tint overlay to match Weekly Practice identity */}
|
| 1478 |
<div className={`pointer-events-none absolute inset-0 rounded-2xl ${isActive ? 'bg-pink-600/70 mix-blend-normal opacity-100' : 'bg-pink-500/30 mix-blend-overlay opacity-35'}`} />
|
| 1479 |
<span className={`relative z-10 text-sm font-medium ${isActive ? 'text-white' : 'text-black'}`}>Week {week}</span>
|
| 1480 |
+
</button>
|
| 1481 |
);
|
| 1482 |
})}
|
| 1483 |
</div>
|
|
|
|
| 1752 |
</div>
|
| 1753 |
{((localStorage.getItem('viewMode')||'auto') !== 'student' && (JSON.parse(localStorage.getItem('user')||'{}').role === 'admin') && showSubmissions) && (
|
| 1754 |
<>
|
| 1755 |
+
<div className="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
|
| 1756 |
+
<div className="mb-4">
|
| 1757 |
+
<h3 className="text-xl font-bold text-gray-900 mb-2">{videoInfo.title}</h3>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1758 |
</div>
|
| 1759 |
+
|
| 1760 |
+
{/* Video Player */}
|
| 1761 |
+
<div className="bg-black rounded-lg aspect-video overflow-hidden relative">
|
| 1762 |
+
<video
|
| 1763 |
+
className="w-full h-full"
|
| 1764 |
+
controls
|
| 1765 |
+
preload="metadata"
|
| 1766 |
+
id="nike-video"
|
| 1767 |
+
onError={(e) => console.error('Video loading error:', e)}
|
| 1768 |
+
onLoadStart={() => console.log('Video loading started')}
|
| 1769 |
+
onCanPlay={() => console.log('Video can play')}
|
| 1770 |
+
>
|
| 1771 |
+
<source src="https://huggingface.co/spaces/linguabot/transcreation-frontend/resolve/main/public/videos/nike-winning-isnt-for-everyone.mp4" type="video/mp4" />
|
| 1772 |
+
Your browser does not support the video tag.
|
| 1773 |
+
</video>
|
| 1774 |
|
| 1775 |
+
{/* On-screen subtitles */}
|
| 1776 |
+
{currentDisplayedSubtitle && (
|
| 1777 |
+
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 z-10">
|
| 1778 |
+
<div className="bg-black bg-opacity-80 text-white px-6 py-3 rounded-lg text-center max-w-lg">
|
| 1779 |
+
<p className={`text-base font-medium leading-relaxed tracking-wide ${currentDisplayedSubtitle.length <= 42 ? 'whitespace-nowrap' : 'whitespace-pre-line'}`} style={{ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }}>
|
| 1780 |
+
{formatSubtitleForDisplay(currentDisplayedSubtitle)}
|
| 1781 |
+
</p>
|
| 1782 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1783 |
</div>
|
| 1784 |
)}
|
| 1785 |
+
</div>
|
| 1786 |
+
</div>
|
| 1787 |
+
|
| 1788 |
+
{/* Right Panel - Translation Workspace */}
|
| 1789 |
+
<div className="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
|
| 1790 |
+
<h3 className="text-xl font-bold text-gray-900 mb-4">Translation Workspace</h3>
|
| 1791 |
+
|
| 1792 |
+
{/* Segment Navigation Grid */}
|
| 1793 |
+
{loadingSubtitles ? (
|
| 1794 |
+
<div className="grid grid-cols-9 gap-1 mb-6">
|
| 1795 |
+
{Array.from({ length: 26 }, (_, i) => (
|
| 1796 |
+
<div key={i} className="px-1 py-1 rounded text-xs w-full bg-gray-200 animate-pulse">
|
| 1797 |
+
{i + 1}
|
| 1798 |
+
</div>
|
| 1799 |
+
))}
|
| 1800 |
+
</div>
|
| 1801 |
+
) : (
|
| 1802 |
+
<div className="grid grid-cols-9 gap-1 mb-6">
|
| 1803 |
+
{subtitleSegments.map((segment) => (
|
| 1804 |
+
<button
|
| 1805 |
+
key={segment.id}
|
| 1806 |
+
onClick={() => handleSegmentClick(segment.id)}
|
| 1807 |
+
className={`px-1 py-1 rounded text-xs w-full ${getSegmentButtonClass(segment.id)}`}
|
| 1808 |
+
>
|
| 1809 |
+
{segment.id}
|
| 1810 |
+
</button>
|
| 1811 |
+
))}
|
| 1812 |
+
</div>
|
| 1813 |
+
)}
|
| 1814 |
+
|
| 1815 |
+
{/* Admin Time Code Editor */}
|
| 1816 |
{editingSegment && (((localStorage.getItem('viewMode')||'auto') === 'student') ? false : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin')) && (
|
| 1817 |
+
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4 mb-4">
|
| 1818 |
+
<h4 className="text-sm font-medium text-indigo-800 mb-2">Edit Time Codes for Segment {editingSegment}</h4>
|
| 1819 |
+
<div className="grid grid-cols-2 gap-2">
|
| 1820 |
+
<div>
|
| 1821 |
+
<label className="block text-xs text-indigo-700 mb-1">Start Time</label>
|
| 1822 |
+
<input
|
| 1823 |
+
type="text"
|
| 1824 |
+
id="startTimeInput"
|
| 1825 |
+
defaultValue={subtitleSegments[editingSegment - 1]?.startTime}
|
| 1826 |
+
className="w-full px-2 py-1 text-xs border border-indigo-300 rounded"
|
| 1827 |
+
placeholder="00:00:00,000"
|
| 1828 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1829 |
</div>
|
| 1830 |
+
<div>
|
| 1831 |
+
<label className="block text-xs text-indigo-700 mb-1">End Time</label>
|
| 1832 |
+
<input
|
| 1833 |
+
type="text"
|
| 1834 |
+
id="endTimeInput"
|
| 1835 |
+
defaultValue={subtitleSegments[editingSegment - 1]?.endTime}
|
| 1836 |
+
className="w-full px-2 py-1 text-xs border border-indigo-300 rounded"
|
| 1837 |
+
placeholder="00:00:00,000"
|
| 1838 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1839 |
</div>
|
| 1840 |
</div>
|
| 1841 |
+
<div className="flex space-x-2 mt-2">
|
| 1842 |
+
<button
|
| 1843 |
+
onClick={() => setEditingSegment(null)}
|
| 1844 |
+
className="px-2 py-1 text-xs bg-gray-500 text-white rounded"
|
| 1845 |
+
>
|
| 1846 |
+
Cancel
|
| 1847 |
+
</button>
|
| 1848 |
+
<button
|
| 1849 |
+
onClick={() => {
|
| 1850 |
+
const startInput = document.getElementById('startTimeInput') as HTMLInputElement;
|
| 1851 |
+
const endInput = document.getElementById('endTimeInput') as HTMLInputElement;
|
| 1852 |
+
if (startInput && endInput && startInput.value && endInput.value) {
|
| 1853 |
+
handleSaveTimeCode(editingSegment, startInput.value, endInput.value);
|
| 1854 |
+
}
|
| 1855 |
+
}}
|
| 1856 |
+
className="px-2 py-1 text-xs bg-indigo-600 text-white rounded"
|
| 1857 |
+
>
|
| 1858 |
+
Save
|
| 1859 |
+
</button>
|
| 1860 |
</div>
|
| 1861 |
</div>
|
| 1862 |
+
)}
|
| 1863 |
+
|
| 1864 |
+
{/* Current Segment Details */}
|
| 1865 |
+
<div className="bg-gray-50 rounded-lg p-4 mb-4 border border-gray-200">
|
| 1866 |
+
<div className="flex items-center justify-between">
|
| 1867 |
+
<p className="text-gray-800">
|
| 1868 |
+
Segment {currentSegment}: {subtitleSegments[currentSegment - 1]?.startTime} – {subtitleSegments[currentSegment - 1]?.endTime} ({subtitleSegments[currentSegment - 1]?.duration})
|
| 1869 |
+
</p>
|
| 1870 |
+
{((localStorage.getItem('viewMode')||'auto') === 'student') ? false : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin') && (
|
| 1871 |
+
<button
|
| 1872 |
+
onClick={() => handleEditTimeCode(currentSegment)}
|
| 1873 |
+
className="bg-indigo-600 hover:bg-indigo-700 text-white px-2 py-1 rounded text-xs flex items-center space-x-1"
|
| 1874 |
+
title="Edit time codes"
|
| 1875 |
+
>
|
| 1876 |
+
<PencilIcon className="w-3 h-3" />
|
| 1877 |
+
<span>Edit</span>
|
| 1878 |
+
</button>
|
| 1879 |
+
)}
|
| 1880 |
</div>
|
| 1881 |
+
</div>
|
| 1882 |
+
|
| 1883 |
+
{/* Source Text */}
|
| 1884 |
+
<div className="mb-4">
|
| 1885 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">Source Text</label>
|
| 1886 |
+
<textarea
|
| 1887 |
+
value={subtitleText}
|
| 1888 |
+
readOnly
|
| 1889 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-800"
|
| 1890 |
+
rows={2}
|
| 1891 |
+
/>
|
| 1892 |
+
</div>
|
| 1893 |
+
|
| 1894 |
+
{/* Target Text */}
|
| 1895 |
+
<div className="mb-4">
|
| 1896 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">Target Text</label>
|
| 1897 |
+
<textarea
|
| 1898 |
+
value={targetText}
|
| 1899 |
+
onChange={(e) => handleTargetTextChange(e.target.value)}
|
| 1900 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
| 1901 |
+
rows={2}
|
| 1902 |
+
placeholder="Enter your translation..."
|
| 1903 |
+
/>
|
| 1904 |
+
<div className="flex justify-between items-center mt-2">
|
| 1905 |
+
<span className="text-xs text-gray-600">Recommended: 2 lines max, 16 chars/line (Netflix standard)</span>
|
| 1906 |
+
<span className="text-xs text-gray-600">{characterCount} characters</span>
|
| 1907 |
</div>
|
| 1908 |
+
</div>
|
| 1909 |
+
|
| 1910 |
+
{/* Action Buttons */}
|
| 1911 |
+
<div className="flex space-x-3 mb-4">
|
| 1912 |
+
<button
|
| 1913 |
+
onClick={handleSaveTranslation}
|
| 1914 |
+
className={`px-4 py-2 rounded-lg ${
|
| 1915 |
+
saveStatus === 'Saved!' ? 'bg-green-600 hover:bg-green-700' :
|
| 1916 |
+
saveStatus === 'Saved Locally' ? 'bg-yellow-600 hover:bg-yellow-700' :
|
| 1917 |
+
saveStatus === 'Error' ? 'bg-red-600 hover:bg-red-700' :
|
| 1918 |
+
'bg-indigo-600 hover:bg-indigo-700'
|
| 1919 |
+
} text-white`}
|
| 1920 |
+
>
|
| 1921 |
+
{saveStatus}
|
| 1922 |
+
</button>
|
| 1923 |
+
<button
|
| 1924 |
+
onClick={handlePreviewTranslation}
|
| 1925 |
+
className={`px-4 py-2 rounded-lg flex items-center space-x-2 ${
|
| 1926 |
+
showTranslatedSubtitles
|
| 1927 |
+
? 'bg-green-500 hover:bg-green-600 text-white'
|
| 1928 |
+
: 'bg-gray-500 hover:bg-gray-600 text-white'
|
| 1929 |
+
}`}
|
| 1930 |
+
>
|
| 1931 |
+
<span>{showTranslatedSubtitles ? 'Show Original' : 'Show Translation'}</span>
|
| 1932 |
+
</button>
|
| 1933 |
+
</div>
|
| 1934 |
+
|
| 1935 |
+
{/* Translation Progress */}
|
| 1936 |
+
<div className="mb-4">
|
| 1937 |
+
<div className="flex justify-between items-center mb-2">
|
| 1938 |
+
<span className="text-sm font-medium text-gray-700">Translation Progress</span>
|
| 1939 |
+
<span className="text-sm text-gray-600">{Object.keys(subtitleTranslations).length} of {subtitleSegments.length} segments completed</span>
|
| 1940 |
</div>
|
| 1941 |
+
<div className="w-full bg-gray-200 rounded-full h-2">
|
| 1942 |
+
<div
|
| 1943 |
+
className="bg-green-500 h-2 rounded-full transition-all duration-300"
|
| 1944 |
+
style={{ width: `${(Object.keys(subtitleTranslations).length / subtitleSegments.length) * 100}%` }}
|
| 1945 |
+
></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1946 |
</div>
|
| 1947 |
</div>
|
| 1948 |
+
</div>
|
| 1949 |
+
</>
|
| 1950 |
+
)}
|
| 1951 |
+
</div>
|
| 1952 |
+
)}
|
| 1953 |
<div className="space-y-6">
|
| 1954 |
{/* Show Subtitling UI Button for Week 2 Admin */}
|
| 1955 |
{selectedWeek === 2 && (() => {
|
|
|
|
| 1959 |
})() && (
|
| 1960 |
<div className="mb-4">
|
| 1961 |
<button onClick={() => setShowSubmissions(v=>!v)} className="px-3 py-1.5 text-sm rounded-md border border-gray-300">{showSubmissions ? 'Hide Subtitling UI' : 'Show Subtitling UI (Admin)'}</button>
|
| 1962 |
+
</div>
|
| 1963 |
+
)}
|
| 1964 |
{/* Add Practice Button for Admin */}
|
| 1965 |
{(() => {
|
| 1966 |
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
|
|
|
| 2592 |
)}
|
| 2593 |
</div>
|
| 2594 |
))}
|
| 2595 |
+
</>
|
| 2596 |
+
)}
|
| 2597 |
+
</div>
|
| 2598 |
|
| 2599 |
{/* Edit Submission Modal */}
|
| 2600 |
{editingSubmission && (
|