TransHub / client /src /pages /TutorialTasks.tsx
linguabot's picture
Upload folder using huggingface_hub
92b4c1c verified
import React, { useState, useEffect, useCallback, useRef, useLayoutEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../services/api';
import TutorialRefinity from '../components/TutorialRefinity';
import {
AcademicCapIcon,
DocumentTextIcon,
CheckCircleIcon,
ClockIcon,
ArrowRightIcon,
PencilIcon,
XMarkIcon,
CheckIcon,
PlusIcon,
TrashIcon,
ArrowTopRightOnSquareIcon,
ArrowsRightLeftIcon
} from '@heroicons/react/24/outline';
// import ReactDOM from 'react-dom';
interface TutorialTask {
_id: string;
content: string;
weekNumber: number;
translationBrief?: string;
imageUrl?: string;
imageAlt?: string;
imageSize?: number;
imageAlignment?: 'left' | 'center' | 'right' | 'portrait-split';
position?: number;
}
interface TutorialWeek {
weekNumber: number;
translationBrief?: string;
tasks: TutorialTask[];
}
interface UserSubmission {
_id: string;
transcreation: string;
status: string;
score: number;
groupNumber?: number;
isOwner?: boolean;
userId?: {
_id: string;
username: string;
};
voteCounts: {
'1': number;
'2': number;
'3': number;
};
}
const TutorialTasks: React.FC = () => {
const [selectedWeek, setSelectedWeek] = useState<number>(() => {
const savedWeek = localStorage.getItem('selectedTutorialWeek');
return savedWeek ? parseInt(savedWeek) : 1;
});
const [isWeekTransitioning, setIsWeekTransitioning] = useState(false);
const [tutorialTasks, setTutorialTasks] = useState<TutorialTask[]>([]);
const [tutorialWeek, setTutorialWeek] = useState<TutorialWeek | null>(null);
const [userSubmissions, setUserSubmissions] = useState<{[key: string]: UserSubmission[]}>({});
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState<{[key: string]: boolean}>({});
const [isWeekHidden, setIsWeekHidden] = useState<boolean>(false);
const [translationText, setTranslationText] = useState<{[key: string]: string}>({});
const [mutatingTaskId, setMutatingTaskId] = useState<string | null>(null);
const [spacerHeights, setSpacerHeights] = useState<{[key: string]: number}>({});
const [expandedSections, setExpandedSections] = useState<{[key: string]: boolean}>({});
const cardRefs = useRef<{[key: string]: HTMLDivElement | null}>({});
const briefRef = useRef<HTMLDivElement | null>(null);
const groupDocRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const submissionsGridRefs = useRef<{[key: string]: HTMLDivElement | null}>({});
const submissionsContainerRefs = useRef<{[key: string]: HTMLDivElement | null}>({});
const withPreservedScroll = useRef<(fn: () => void) => void>();
const pendingUnlocksRef = useRef<Set<string>>(new Set());
const lastPreHeightRef = useRef<{[key: string]: number}>({});
const disableCompensationRef = useRef<Set<string>>(new Set());
const setGlobalAnchorDisabled = (disabled: boolean) => {
try {
if (disabled) {
document.documentElement.style.setProperty('overflow-anchor', 'none');
document.body.style.setProperty('overflow-anchor', 'none');
} else {
document.documentElement.style.removeProperty('overflow-anchor');
document.body.style.removeProperty('overflow-anchor');
}
} catch {}
};
// Last-resort scroll freeze during tiny mutation window (no visual/layout change)
const scrollLockState = useRef<{ y: number } | null>(null);
const freezeScroll = () => {
try {
if (scrollLockState.current) return; // already frozen
const y = window.scrollY;
scrollLockState.current = { y };
try { console.log('[Trace] Safari:freezeScroll', { y }); } catch {}
const body = document.body as HTMLElement;
body.style.position = 'fixed';
body.style.top = `-${y}px`;
body.style.width = '100%';
body.style.overflowY = 'scroll';
} catch {}
};
const unfreezeScroll = () => {
try {
const state = scrollLockState.current;
if (!state) return;
const body = document.body as HTMLElement;
body.style.position = '';
body.style.top = '';
body.style.width = '';
body.style.overflowY = '';
window.scrollTo(0, state.y);
scrollLockState.current = null;
try { console.log('[Trace] Safari:unfreezeScroll'); } catch {}
} catch {}
};
// Minimal, local scroll-stability helpers for submit/delete
// Basic UA check; Chrome on iOS uses WKWebView but includes 'CriOS'
const isSafari = typeof navigator !== 'undefined' && /Safari\//.test(navigator.userAgent) && !/Chrome\//.test(navigator.userAgent) && !/CriOS\//.test(navigator.userAgent);
const SAFARI_FREEZE_ENABLED = false; // disable body-freeze so scroll compensation can apply
const lockListHeight = () => {
const el = listRef.current;
if (!el) return;
const h = el.getBoundingClientRect().height;
el.style.minHeight = `${h}px`;
el.style.height = `${h}px`;
el.style.overflow = 'hidden';
try { console.log('[Trace] lockListHeight', { h }); } catch {}
};
const unlockListHeight = () => {
const el = listRef.current;
if (!el) return;
el.style.overflow = '';
el.style.height = '';
el.style.minHeight = '';
try { console.log('[Trace] unlockListHeight'); } catch {}
};
const lockCardHeightById = (id: string) => {
const el = cardRefs.current[id];
if (!el) return;
const h = el.getBoundingClientRect().height;
el.style.minHeight = `${h}px`;
el.style.height = `${h}px`;
el.style.overflow = 'hidden';
try { console.log('[Trace] lockCardHeight', { id, h }); } catch {}
};
const unlockCardHeightById = (id: string) => {
const el = cardRefs.current[id];
if (!el) return;
el.style.overflow = '';
el.style.height = '';
el.style.minHeight = '';
try { console.log('[Trace] unlockCardHeight', { id }); } catch {}
};
const lockGridHeightById = (id: string) => {
const el = submissionsGridRefs.current[id];
if (!el) return;
const h = el.getBoundingClientRect().height;
el.style.minHeight = `${h}px`;
el.style.height = `${h}px`;
el.style.overflow = 'hidden';
try { console.log('[Trace] lockGridHeight', { id, h }); } catch {}
};
const unlockGridHeightById = (id: string) => {
const el = submissionsGridRefs.current[id];
if (!el) return;
el.style.overflow = '';
el.style.height = '';
el.style.minHeight = '';
try { console.log('[Trace] unlockGridHeight', { id }); } catch {}
};
// No-op container height locks (reverted)
const lockContainerHeightById = (_id: string) => {};
const unlockContainerHeightById = (_id: string) => {};
// (removed) ResizeObserver scroll compensator
const withPreservedCardOffset = (taskId: string, fn: () => void) => {
// If scroll is frozen, avoid additional scroll adjustments that can fight WebKit
if (scrollLockState.current) {
fn();
return;
}
if (disableCompensationRef.current.has(taskId)) {
fn();
return;
}
if ((lastPreHeightRef.current[taskId] || 0) === 0) {
fn();
return;
}
const el = cardRefs.current[taskId];
const topBefore = el ? el.getBoundingClientRect().top : null;
const scrollYBefore = window.scrollY;
fn();
requestAnimationFrame(() => {
const topAfter = el ? el.getBoundingClientRect().top : null;
if (topBefore !== null && topAfter !== null) {
const delta = topAfter - topBefore;
const clampedDelta = Math.max(-150, Math.min(150, delta));
const allowComp = isSafari ? Math.abs(clampedDelta) > 0 : true;
if (delta !== 0 && allowComp) {
try { console.log('[Trace] ScrollPreserve', { taskId, delta, topBefore, topAfter, scrollYBefore }); } catch {}
// Invert clamped delta to counteract element movement (keep card anchored)
window.scrollBy(0, -clampedDelta);
}
} else {
window.scrollTo(0, scrollYBefore);
}
});
};
// Initialize scroll preservation helper once
useLayoutEffect(() => {
withPreservedScroll.current = (fn: () => void) => {
try {
const y = window.scrollY;
fn();
// Restore scroll position after DOM updates with multiple frames
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.scrollTo(0, y);
});
});
});
} catch {
fn();
}
};
}, []);
// Measure static containers once to avoid reflow jumps (Week 1 only prominent)
// Move a task up or down by normalizing positions for the current visible list (weeks 4–6 only)
const moveTask = async (taskId: string, direction: 'up' | 'down') => {
try {
const viewMode = (localStorage.getItem('viewMode') || 'auto');
const actualRole = (JSON.parse(localStorage.getItem('user') || '{}').role);
const isAdmin = viewMode === 'student' ? false : actualRole === 'admin';
if (!isAdmin || selectedWeek < 4) return;
// Build ordered list for the current week from what is rendered
const current = tutorialTasks.filter(t => t.weekNumber === selectedWeek);
const index = current.findIndex(t => t._id === taskId);
if (index === -1) return;
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= current.length) return;
// Normalize positions to 0..n-1 based on current screen order
const normalized = current.map((t, i) => ({ id: t._id, position: i }));
// Swap the two entries (calculate new positions first)
const posA = normalized[index].position;
const posB = normalized[targetIndex].position;
normalized[index].position = posB;
normalized[targetIndex].position = posA;
// Optimistic UI update: swap in local state immediately for smoother UX
// const prevState = tutorialTasks; // not used
setTutorialTasks((prev) => {
const next = [...prev];
// find actual indices in full list and swap their relative order by updating their position fields
const aId = normalized[index].id;
const bId = normalized[targetIndex].id;
return next.map(item => {
if (item._id === aId) return { ...item, position: posB } as any;
if (item._id === bId) return { ...item, position: posA } as any;
return item;
});
});
// Send both updates in parallel; if either fails, revert then refetch
await Promise.all([
api.put(`/api/auth/admin/tutorial-tasks/${normalized[index].id}/position`, { position: posB }),
api.put(`/api/auth/admin/tutorial-tasks/${normalized[targetIndex].id}/position`, { position: posA })
]);
// Light refresh to ensure list order is consistent with server
fetchTutorialTasks(false);
} catch (error) {
console.error('Reorder failed', error);
}
};
const [selectedGroups, setSelectedGroups] = useState<{[key: string]: number}>({});
const GroupDocSection: React.FC<{ weekNumber: number }> = ({ weekNumber }) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [group, setGroup] = useState<number>(() => {
const saved = localStorage.getItem(`tutorial_group_${weekNumber}`);
return saved ? parseInt(saved) : 1;
});
const [creating, setCreating] = useState(false);
const [docs, setDocs] = useState<any[]>([]);
const [urlInput, setUrlInput] = useState<string>('');
const [errorMsg, setErrorMsg] = useState<string>('');
const [copiedLink, setCopiedLink] = useState<string>('');
const viewMode = (localStorage.getItem('viewMode') || 'auto');
const actualRole = (JSON.parse(localStorage.getItem('user') || '{}').role);
const isAdmin = viewMode === 'student' ? false : actualRole === 'admin';
const isDocsFetchInFlightRef = useRef(false);
const CopySquaresIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className || 'h-4 w-4'} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="8" width="12" height="12" rx="2"/>
<rect x="4" y="4" width="12" height="12" rx="2"/>
</svg>
);
const loadDocs = useCallback(async () => {
try {
// throttle fetch during and just after mutations to avoid reflow interference
if (mutatingTaskId) {
try { console.log('[Trace] Docs:skipDuringMutate', { weekNumber, mutatingTaskId }); } catch {}
setTimeout(() => { if (!mutatingTaskId) loadDocs(); }, 600);
return;
}
if (isDocsFetchInFlightRef.current) {
try { console.log('[Trace] Docs:skipInFlight', { weekNumber }); } catch {}
return;
}
isDocsFetchInFlightRef.current = true;
try { console.log('[Trace] Docs:fetch', { weekNumber }); } catch {}
const resp = await api.get(`/api/docs/list?weekNumber=${weekNumber}`);
setDocs(resp.data?.docs || []);
} catch (e) {
setDocs([]);
} finally {
isDocsFetchInFlightRef.current = false;
}
}, [weekNumber, mutatingTaskId]);
useEffect(() => { loadDocs(); }, [loadDocs]);
const current = docs.find(d => d.groupNumber === group);
const createDoc = async () => {
try {
setCreating(true);
setErrorMsg('');
const url = urlInput.trim();
if (!url) {
setErrorMsg('Please paste a Google Doc link.');
return;
}
const isValid = /docs\.google\.com\/document\/d\//.test(url);
if (!isValid) {
setErrorMsg('Provide a valid Google Doc link (docs.google.com/document/d/...).');
return;
}
await api.post('/api/docs/create', { weekNumber, groupNumber: group, docUrl: url });
await loadDocs();
setUrlInput('');
} finally {
setCreating(false);
}
};
const copyLink = async (link: string) => {
try {
await navigator.clipboard.writeText(link);
// Persist until refresh: do not clear after timeout
setCopiedLink(link);
} catch {}
};
useEffect(() => {
const el = containerRef.current;
if (!el) return;
if (mutatingTaskId) {
const h = el.getBoundingClientRect().height;
try { console.log('[Trace] Docs:lockHeight', { h }); } catch {}
el.style.minHeight = `${h}px`;
el.style.height = `${h}px`;
el.style.overflow = 'hidden';
} else {
el.style.minHeight = '';
el.style.height = '';
el.style.overflow = '';
try { console.log('[Trace] Docs:unlockHeight'); } catch {}
}
}, [mutatingTaskId]);
return (
<div ref={containerRef}>
{/* Top control row */}
{isAdmin && (
<div className="mb-4 max-w-2xl">
<label className="text-sm text-gray-700 block mb-1">Group</label>
<select
value={group}
onChange={(e) => {
const g = parseInt(e.target.value);
setGroup(g);
localStorage.setItem(`tutorial_group_${weekNumber}`, String(g));
}}
className="w-full px-3 py-2 border rounded-md text-sm"
>
{[1,2,3,4,5,6,7,8].map(g => <option key={g} value={g}>Group {g}</option>)}
</select>
</div>
)}
{/* Replace / Add link inline editor */}
{isAdmin && (
<div className="mb-4 max-w-2xl">
<div className="flex items-center gap-2">
<input
type="url"
value={urlInput}
onChange={(e) => { setUrlInput(e.target.value); setErrorMsg(''); }}
placeholder="Paste Google Doc link (docs.google.com/document/d/...)"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm"
/>
<button onClick={createDoc} disabled={creating} className="relative inline-flex items-center justify-center gap-2 px-4 py-2 rounded-2xl text-sm font-medium text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 shadow-[inset_0_1px_0_rgba(255,255,255,0.6),inset_0_-1px_0_rgba(0,0,0,0.12)] bg-sky-600/70 disabled:bg-gray-400">{creating ? 'Saving…' : (current ? 'Save new link' : 'Add Doc Link')}</button>
</div>
{errorMsg && <div className="mt-1 text-xs text-red-600">{errorMsg}</div>}
</div>
)}
{/* All groups table */}
<div className="mt-6 max-w-2xl">
<h5 className="text-sm font-semibold text-gray-800 uppercase tracking-wide mb-2">All Groups</h5>
<div className="overflow-hidden rounded-lg border border-gray-200">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="px-4 py-2 text-left">Group</th>
<th className="px-4 py-2 text-left">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{docs.length === 0 && (
<tr><td colSpan={2} className="px-4 py-3 text-gray-500">No group docs yet.</td></tr>
)}
{docs.map(d => (
<tr key={d._id}>
<td className="px-4 py-3">Group {d.groupNumber}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-4">
<a href={d.docUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-ui-neonBlue">
<ArrowTopRightOnSquareIcon className="h-4 w-4" /> Open
</a>
<button onClick={() => copyLink(d.docUrl)} className="inline-flex items-center gap-1 text-ui-neonBlue">
<CopySquaresIcon className="h-4 w-4" />
<span className="inline-block w-14 text-left">{copiedLink === d.docUrl ? 'Copied' : 'Copy'}</span>
</button>
{isAdmin && (
<>
<button onClick={() => { setGroup(d.groupNumber); setUrlInput(d.docUrl || ''); }} className="inline-flex items-center gap-1 text-gray-700">
<ArrowsRightLeftIcon className="h-4 w-4" /> Edit
</button>
<button
onClick={async () => {
try {
await api.delete(`/api/docs/${d._id}`);
await loadDocs();
} catch (e) {
console.error('Delete failed', e);
}
}}
className="inline-flex items-center gap-1 text-red-600"
>
<TrashIcon className="h-4 w-4" /> Delete
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
// Basic inline formatting helpers (bold/italic via simple markdown) for weeks 4–6
const renderFormatted = (text: string) => {
const escape = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Auto-linker: supports [label](url), plain URLs, and www.* without touching existing href attributes
const html = escape(text)
// Markdown-style links: [label](https://example.com)
.replace(/\[([^\]]+?)\]\((https?:\/\/[^\s)]+)\)/g, '<a class="text-indigo-600 underline" href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
// Plain URLs with protocol, avoid matching inside attributes (require a non-attribute preceding char)
.replace(/(^|[^=\"'\/:])(https?:\/\/[^\s<]+)/g, (m, p1, url) => `${p1}<a class="text-indigo-600 underline" href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`)
// www.* domains (prepend https://), also avoid attributes
.replace(/(^|[^=\"'\/:])(www\.[^\s<]+)/g, (m, p1, host) => `${p1}<a class="text-indigo-600 underline" href="https://${host}" target="_blank" rel="noopener noreferrer">${host}</a>`)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\n/g, '<br/>');
return <span dangerouslySetInnerHTML={{ __html: html.replace(/text-indigo-600/g, 'text-ui-neonBlue').replace(/text-indigo-900/g, 'text-ui-text') }} />;
};
const applyLinkFormat = (
elementId: string,
current: string,
setValue: (v: string) => void
) => {
const urlInput = window.prompt('Enter URL (e.g., https://example.com):');
if (!urlInput) return;
// Sanitize URL: ensure protocol, and strip accidental trailing quotes/attributes pasted from elsewhere
let url = /^https?:\/\//i.test(urlInput) ? urlInput : `https://${urlInput}`;
url = url.replace(/["'>)\s]+$/g, '');
const el = document.getElementById(elementId) as HTMLTextAreaElement | null;
if (!el) {
setValue(`${current}[link](${url})`);
return;
}
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? current.length;
const before = current.slice(0, start);
const selection = current.slice(start, end) || 'link';
const after = current.slice(end);
setValue(`${before}[${selection}](${url})${after}`);
// Restore focus and selection
setTimeout(() => {
el.focus();
const newPos = before.length + selection.length + 4 + url.length + 2; // rough caret placement
try { el.setSelectionRange(newPos, newPos); } catch {}
}, 0);
};
const applyInlineFormat = (
elementId: string,
current: string,
setValue: (v: string) => void,
wrapper: '**' | '*'
) => {
const el = document.getElementById(elementId) as HTMLTextAreaElement | null;
if (!el) {
setValue(current + wrapper + wrapper);
return;
}
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? current.length;
const before = current.slice(0, start);
const selection = current.slice(start, end);
const after = current.slice(end);
const next = `${before}${wrapper}${selection}${wrapper}${after}`;
setValue(next);
setTimeout(() => {
try {
el.focus();
el.selectionStart = start + wrapper.length;
el.selectionEnd = end + wrapper.length;
} catch {}
}, 0);
};
const [editingTask, setEditingTask] = useState<string | null>(null);
const [editingBrief, setEditingBrief] = useState<{[key: number]: boolean}>({});
const [addingTask, setAddingTask] = useState<boolean>(false);
const [addingImage, setAddingImage] = useState<boolean>(false);
const [addingRevision, setAddingRevision] = useState<boolean>(false);
const [revisionAdded, setRevisionAdded] = useState<boolean>(false);
const [hasRevisionTasks, setHasRevisionTasks] = useState<boolean>(false);
// Check if Deep Revision tasks exist for the current week
React.useEffect(() => {
const checkForRevisionTasks = async () => {
if (selectedWeek < 5) {
setHasRevisionTasks(false);
return;
}
try {
// Use the same API base detection as other parts of the app
const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, '');
const tasksUrl = `${base}/api/tutorial-refinity/tasks?weekNumber=${selectedWeek}`;
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(()=>[]);
const hasTasks = Array.isArray(data) && data.length > 0;
setHasRevisionTasks(hasTasks);
// If tasks exist, automatically show the revision module
if (hasTasks) {
setRevisionAdded(true);
// Also save to localStorage for consistency
try {
localStorage.setItem(`tutorial_revision_added_week_${selectedWeek}`, 'true');
} catch (e) {
// Ignore localStorage errors
}
}
} catch (e) {
console.warn('Failed to check for revision tasks:', e);
setHasRevisionTasks(false);
}
};
checkForRevisionTasks();
}, [selectedWeek]);
// Initialize and update revisionAdded state from localStorage (Safari-compatible)
// Use useEffect instead of useState initializer for better Safari compatibility
React.useEffect(() => {
const checkRevisionState = () => {
try {
const saved = localStorage.getItem(`tutorial_revision_added_week_${selectedWeek}`);
const isAdded = saved === 'true';
// Only set from localStorage if tasks don't exist (to avoid overriding the auto-show logic)
if (!hasRevisionTasks) {
setRevisionAdded(isAdded);
}
} catch (e) {
// Safari might block localStorage in some contexts, fallback to false
if (!hasRevisionTasks) {
setRevisionAdded(false);
}
}
};
// Check immediately
checkRevisionState();
// Also check after a short delay for Safari (in case localStorage isn't ready immediately)
const timeoutId = setTimeout(checkRevisionState, 100);
return () => clearTimeout(timeoutId);
}, [selectedWeek, hasRevisionTasks]);
const [editForm, setEditForm] = useState<{
content: string;
translationBrief: string;
imageUrl: string;
imageAlt: string;
}>({
content: '',
translationBrief: '',
imageUrl: '',
imageAlt: ''
});
const [imageForm, setImageForm] = useState<{
imageUrl: string;
imageAlt: string;
imageSize: number;
imageAlignment: 'left' | 'center' | 'right' | 'portrait-split';
}>({
imageUrl: '',
imageAlt: '',
imageSize: 200,
imageAlignment: 'center'
});
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
const navigate = useNavigate();
const weeks = [1, 2, 3, 4, 5];
const isAdmin = (() => {
try {
const viewMode = (localStorage.getItem('viewMode')||'auto');
const role = JSON.parse(localStorage.getItem('user')||'{}').role;
return (viewMode !== 'student') && role === 'admin';
} catch { return false; }
})();
const handleWeekChange = async (week: number) => {
setIsWeekTransitioning(true);
// Clear existing data first
setTutorialTasks([]);
setTutorialWeek(null);
setUserSubmissions({});
// Update state and localStorage
setSelectedWeek(week);
localStorage.setItem('selectedTutorialWeek', week.toString());
// Force a small delay to ensure state is updated
await new Promise(resolve => setTimeout(resolve, 50));
// Wait for actual content to load before ending animation
try {
// Fetch new week's data with the updated selectedWeek
let response;
try {
response = await api.get(`/api/search/tutorial-tasks/${week}`);
} catch (e) {
// Soft retry once on transient errors
response = await api.get(`/api/search/tutorial-tasks/${week}`);
}
if (response.data) {
const tasks = response.data;
console.log('Fetched tasks for week', week, ':', tasks);
// Ensure tasks are sorted by title
const sortedTasks = tasks.sort((a: any, b: any) => {
const aNum = parseInt(a.title?.match(/ST (\d+)/)?.[1] || '0');
const bNum = parseInt(b.title?.match(/ST (\d+)/)?.[1] || '0');
return aNum - bNum;
});
setTutorialTasks(sortedTasks);
// Use translation brief from tasks; if none and no tasks, only fall back to localStorage for admins
let translationBrief = null as string | null;
if (tasks.length > 0) {
translationBrief = tasks[0].translationBrief;
} else {
const briefKey = `translationBrief_week_${week}`;
const viewMode = (localStorage.getItem('viewMode')||'auto');
const role = (JSON.parse(localStorage.getItem('user')||'{}').role);
const isAdminView = (viewMode !== 'student') && role === 'admin';
translationBrief = isAdminView ? localStorage.getItem(briefKey) : '';
}
const tutorialWeekData: TutorialWeek = {
weekNumber: week,
translationBrief: (translationBrief ?? undefined),
tasks: tasks
};
setTutorialWeek(tutorialWeekData);
// Fetch user submissions for the new tasks
await fetchUserSubmissions(tasks);
}
// Wait longer for DOM to update with new content (especially for Week 2)
const delay = week === 2 ? 400 : 200;
await new Promise(resolve => setTimeout(resolve, delay));
} catch (error) {
console.error('Error loading week data:', error);
} finally {
// End transition after content is loaded and rendered
setIsWeekTransitioning(false);
}
};
useEffect(() => {
const loadVisibility = async () => {
try {
const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, '');
const resp = await fetch(`${base}/api/admin/weeks/tutorial/${selectedWeek}/visibility`, {
headers: {
'Authorization': localStorage.getItem('token') ? `Bearer ${localStorage.getItem('token')}` : '',
'user-role': 'admin'
}
});
const json = await resp.json().catch(() => ({}));
setIsWeekHidden(!!json?.week?.hidden);
} catch (e) { /* noop */ }
};
loadVisibility();
}, [selectedWeek]);
const handleFileUpload = async (file: File): Promise<string> => {
try {
setUploading(true);
// Convert file to data URL for display
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
console.log('File uploaded:', file.name, 'Size:', file.size);
console.log('Generated data URL:', dataUrl.substring(0, 50) + '...');
resolve(dataUrl);
};
reader.onerror = () => {
console.error('Error reading file:', reader.error);
reject(reader.error);
};
reader.readAsDataURL(file);
});
} catch (error) {
console.error('Error uploading file:', error);
throw error;
} finally {
setUploading(false);
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setSelectedFile(file);
}
};
const toggleExpanded = (taskId: string) => {
setExpandedSections(prev => ({
...prev,
[taskId]: !prev[taskId]
}));
};
const fetchUserSubmissions = useCallback(async (tasks: TutorialTask[]) => {
try {
const token = localStorage.getItem('token');
const user = localStorage.getItem('user');
if (!token || !user) {
return;
}
const response = await api.get('/api/submissions/my-submissions');
if (response.data && response.data.submissions) {
const data = response.data;
const groupedSubmissions: {[key: string]: UserSubmission[]} = {};
// Initialize all tasks with empty arrays
tasks.forEach(task => {
groupedSubmissions[task._id] = [];
});
// Then populate with actual submissions and mark ownership for edit visibility after refresh/login
if (data.submissions && Array.isArray(data.submissions)) {
data.submissions.forEach((submission: any) => {
const sourceTextId = submission.sourceTextId?._id || submission.sourceTextId;
if (sourceTextId && groupedSubmissions[sourceTextId]) {
groupedSubmissions[sourceTextId].push({ ...submission, isOwner: true });
}
});
}
setUserSubmissions(groupedSubmissions);
}
} catch (error) {
console.error('Error fetching user submissions:', error);
}
}, []);
const fetchTutorialTasks = useCallback(async (showLoading = true) => {
try {
if (showLoading) {
setLoading(true);
}
const token = localStorage.getItem('token');
const user = localStorage.getItem('user');
if (!token || !user) {
navigate('/login');
return;
}
let response;
try {
response = await api.get(`/api/search/tutorial-tasks/${selectedWeek}`);
} catch (e) {
response = await api.get(`/api/search/tutorial-tasks/${selectedWeek}`);
}
if (response.data) {
const tasks = response.data;
console.log('Fetched tasks:', tasks);
console.log('Tasks with images:', tasks.filter((task: TutorialTask) => task.imageUrl));
// Debug: Log each task's fields
tasks.forEach((task: any, index: number) => {
console.log(`Task ${index} fields:`, {
_id: task._id,
content: task.content,
imageUrl: task.imageUrl,
imageAlt: task.imageAlt,
translationBrief: task.translationBrief,
weekNumber: task.weekNumber,
category: task.category
});
console.log(`Task ${index} imageUrl:`, task.imageUrl);
console.log(`Task ${index} translationBrief:`, task.translationBrief);
});
// Ensure tasks are sorted by title to maintain correct order (Tutorial ST 1, Tutorial ST 2, etc.)
const sortedTasks = tasks.sort((a: any, b: any) => {
const aNum = parseInt(a.title?.match(/ST (\d+)/)?.[1] || '0');
const bNum = parseInt(b.title?.match(/ST (\d+)/)?.[1] || '0');
return aNum - bNum;
});
setTutorialTasks(sortedTasks);
// Use translation brief from tasks or localStorage
let translationBrief = null;
if (tasks.length > 0) {
translationBrief = tasks[0].translationBrief;
console.log('Translation brief from task:', translationBrief);
} else {
// Check localStorage for brief if no tasks exist
const briefKey = `translationBrief_week_${selectedWeek}`;
translationBrief = localStorage.getItem(briefKey);
console.log('Translation brief from localStorage:', translationBrief);
console.log('localStorage key:', briefKey);
}
console.log('Final translation brief:', translationBrief);
const tutorialWeekData: TutorialWeek = {
weekNumber: selectedWeek,
translationBrief: translationBrief,
tasks: tasks
};
setTutorialWeek(tutorialWeekData);
await fetchUserSubmissions(tasks);
} else {
console.error('Failed to fetch tutorial tasks');
}
} catch (error) {
console.error('Error fetching tutorial tasks:', error);
} finally {
if (showLoading) {
setLoading(false);
}
}
}, [selectedWeek, fetchUserSubmissions, navigate]);
useEffect(() => {
const user = localStorage.getItem('user');
if (!user) {
navigate('/login');
return;
}
fetchTutorialTasks();
}, [fetchTutorialTasks, navigate]);
// Listen for week reset events from page navigation
useEffect(() => {
const handleWeekReset = (event: CustomEvent) => {
if (event.detail.page === 'tutorial-tasks') {
console.log('Week reset event received for tutorial tasks');
setSelectedWeek(event.detail.week);
localStorage.setItem('selectedTutorialWeek', event.detail.week.toString());
}
};
window.addEventListener('weekReset', handleWeekReset as EventListener);
return () => {
window.removeEventListener('weekReset', handleWeekReset as EventListener);
};
}, []);
// Refresh submissions when user changes (after login/logout)
useEffect(() => {
const user = localStorage.getItem('user');
if (user && tutorialTasks.length > 0) {
fetchUserSubmissions(tutorialTasks);
}
}, [tutorialTasks, fetchUserSubmissions]);
const handleSubmitTranslation = async (taskId: string, localText?: string, localGroup?: number) => {
const text = localText || translationText[taskId];
const group = localGroup || selectedGroups[taskId];
if (!text?.trim()) {
return;
}
if (!group) {
return;
}
try {
setMutatingTaskId(taskId);
if (isSafari && SAFARI_FREEZE_ENABLED) {
try { (document.activeElement as HTMLElement | null)?.blur?.(); } catch {}
freezeScroll();
}
lockListHeight();
lockCardHeightById(taskId);
lockGridHeightById(taskId);
withPreservedCardOffset(taskId, () => {
setSubmitting({ ...submitting, [taskId]: true });
});
const user = JSON.parse(localStorage.getItem('user') || '{}');
const t0 = performance.now();
const response = await new Promise((resolve) => requestAnimationFrame(async () => {
const res = await api.post('/api/submissions', {
sourceTextId: taskId,
transcreation: text,
groupNumber: group,
culturalAdaptations: [],
username: user.name || 'Unknown'
});
resolve(res);
})) as any;
try { console.log('[Trace] Submit:response', { taskId, dt: Math.round(performance.now() - t0) }); } catch {}
if (response.status >= 200 && response.status < 300) {
const created = response.data;
console.log('Submission created successfully:', created);
// Optimistic append to avoid large reflow from full refetch
setUserSubmissions(prev => {
const current = prev[taskId] || [];
// Put newest first like server returns
const next = [{ ...created, isOwner: true }, ...current];
return { ...prev, [taskId]: next };
});
// Defer state updates and minimal refetch
// Measure grid height and set spacer before refetch to keep layout height constant
const gridEl = submissionsGridRefs.current[taskId];
const containerEl = submissionsContainerRefs.current[taskId];
const cardEl = cardRefs.current[taskId];
const preGridH = gridEl ? gridEl.getBoundingClientRect().height : 0;
const preContH = containerEl ? containerEl.getBoundingClientRect().height : 0;
const preCardH = cardEl ? cardEl.getBoundingClientRect().height : 0;
const preHeight = preGridH > 0 ? preGridH : (preContH > 0 ? preContH : preCardH);
try { console.log('[Trace] Submit:preHeights', { taskId, preGridH, preContH, preCardH, chosen: preHeight }); } catch {}
if (!isSafari && preHeight > 0) setSpacerHeights(prev => ({ ...prev, [taskId]: preHeight }));
if (isSafari) { try { console.log('[Trace] Submit:preHeights', { taskId, preGridH, preContH, preCardH, chosen: preHeight }); } catch {} }
lastPreHeightRef.current[taskId] = preHeight;
disableCompensationRef.current.add(taskId);
withPreservedCardOffset(taskId, () => {
try {
const el = cardRefs.current[taskId];
const topBefore = el ? el.getBoundingClientRect().top : null;
console.log('[Trace] ScrollPreserve:before', { taskId, topBefore });
} catch {}
React.startTransition(() => {
setTranslationText({ ...translationText, [taskId]: '' });
setSelectedGroups({ ...selectedGroups, [taskId]: 0 });
});
if (!isSafari) {
// Narrow refetch immediately for non-Safari
pendingUnlocksRef.current.add(taskId);
api.get(`/api/submissions/by-source/${taskId}`).then(r => {
const list = (r.data && r.data.submissions) || [];
setUserSubmissions(prev => ({ ...prev, [taskId]: list }));
requestAnimationFrame(() => setSpacerHeights(prev => ({ ...prev, [taskId]: 0 })));
}).catch(() => {
requestAnimationFrame(() => setSpacerHeights(prev => ({ ...prev, [taskId]: 0 })));
});
} else {
// Safari: delay refetch and keep locks until refetch completes
pendingUnlocksRef.current.add(taskId);
setTimeout(() => {
api.get(`/api/submissions/by-source/${taskId}`).then(r => {
const list = (r.data && r.data.submissions) || [];
setUserSubmissions(prev => ({ ...prev, [taskId]: list }));
requestAnimationFrame(() => {
// Allow scroll compensation at unlock time
disableCompensationRef.current.delete(taskId);
setSpacerHeights(prev => ({ ...prev, [taskId]: 0 }));
withPreservedCardOffset(taskId, () => {
unlockListHeight();
unlockCardHeightById(taskId);
unlockGridHeightById(taskId);
unlockContainerHeightById(taskId);
pendingUnlocksRef.current.delete(taskId);
});
});
}).catch(() => {
requestAnimationFrame(() => {
disableCompensationRef.current.delete(taskId);
setSpacerHeights(prev => ({ ...prev, [taskId]: 0 }));
withPreservedCardOffset(taskId, () => {
unlockListHeight();
unlockCardHeightById(taskId);
unlockGridHeightById(taskId);
unlockContainerHeightById(taskId);
pendingUnlocksRef.current.delete(taskId);
});
});
});
}, 300);
}
});
} else {
console.error('[Trace] Submit:Error', response.data);
}
} catch (error) {
console.error('[Trace] Submit:Exception', error);
} finally {
withPreservedCardOffset(taskId, () => {
setSubmitting({ ...submitting, [taskId]: false });
});
// release after a couple frames to let DOM settle (extra frame on Safari)
const release = () => {
// If a gated refetch is in-flight, defer unlock until postRefetch block
if (pendingUnlocksRef.current.has(taskId)) return;
unlockListHeight();
unlockCardHeightById(taskId);
unlockGridHeightById(taskId);
unlockContainerHeightById(taskId);
if (isSafari && SAFARI_FREEZE_ENABLED) unfreezeScroll();
};
if (isSafari) {
requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(release)));
} else {
requestAnimationFrame(() => requestAnimationFrame(release));
}
setMutatingTaskId(null);
}
};
const [editingSubmission, setEditingSubmission] = useState<{id: string, text: string} | null>(null);
const [editSubmissionText, setEditSubmissionText] = useState('');
const handleEditSubmission = async (submissionId: string, currentText: string) => {
setEditingSubmission({ id: submissionId, text: currentText });
setEditSubmissionText(currentText);
};
const saveEditedSubmission = async () => {
if (!editingSubmission || !editSubmissionText.trim()) return;
try {
const response = await api.put(`/api/submissions/${editingSubmission.id}`, {
transcreation: editSubmissionText
});
if (response.status >= 200 && response.status < 300) {
// Defer all state updates to prevent UI jumping
React.startTransition(() => {
setEditingSubmission(null);
setEditSubmissionText('');
fetchUserSubmissions(tutorialTasks);
});
} else {
console.error('Failed to update translation:', response.data);
}
} catch (error) {
console.error('Error updating translation:', error);
}
};
const cancelEditSubmission = () => {
setEditingSubmission(null);
setEditSubmissionText('');
};
const handleDeleteSubmission = async (submissionId: string, taskId?: string) => {
try {
if (taskId) setMutatingTaskId(taskId);
if (isSafari && SAFARI_FREEZE_ENABLED) { freezeScroll(); }
lockListHeight();
if (taskId) {
lockCardHeightById(taskId);
lockGridHeightById(taskId);
}
const t0 = performance.now();
const response = await api.delete(`/api/submissions/${submissionId}`);
if (response.status === 200) {
try { console.log('[Trace] Delete:response', { taskId, submissionId, dt: Math.round(performance.now() - t0) }); } catch {}
// Optimistic removal (disabled on Safari to prevent multi-phase jumps)
if (taskId && !isSafari) {
setUserSubmissions(prev => {
const list = prev[taskId] || [];
const next = list.filter(s => s._id !== submissionId);
return { ...prev, [taskId]: next };
});
}
// Defer refetch to prevent UI jumping and preserve scroll around DOM updates
if (taskId) {
const gridEl = submissionsGridRefs.current[taskId];
const containerEl = submissionsContainerRefs.current[taskId];
const cardEl = cardRefs.current[taskId];
const preGridH = gridEl ? gridEl.getBoundingClientRect().height : 0;
const preContH = containerEl ? containerEl.getBoundingClientRect().height : 0;
const preCardH = cardEl ? cardEl.getBoundingClientRect().height : 0;
const preHeight = preGridH > 0 ? preGridH : (preContH > 0 ? preContH : preCardH);
lastPreHeightRef.current[taskId] = preHeight;
try { console.log('[Trace] Delete:preHeights', { taskId, preGridH, preContH, preCardH, chosen: preHeight }); } catch {}
}
withPreservedCardOffset(taskId || '', () => {
try {
if (taskId) {
const el = cardRefs.current[taskId];
const topBefore = el ? el.getBoundingClientRect().top : null;
console.log('[Trace] ScrollPreserve(del):before', { taskId, topBefore });
}
} catch {}
React.startTransition(() => {
// Narrow refetch: only this task's submissions
if (taskId) {
if (!isSafari) {
const gridEl = submissionsGridRefs.current[taskId];
const gridHeight = gridEl ? gridEl.getBoundingClientRect().height : 0;
if (gridHeight > 0) setSpacerHeights(prev => ({ ...prev, [taskId]: gridHeight }));
api.get(`/api/submissions/by-source/${taskId}`).then(r => {
const list = (r.data && r.data.submissions) || [];
setUserSubmissions(prev => ({ ...prev, [taskId]: list }));
requestAnimationFrame(() => setSpacerHeights(prev => ({ ...prev, [taskId]: 0 })));
}).catch(() => {
requestAnimationFrame(() => setSpacerHeights(prev => ({ ...prev, [taskId]: 0 })));
});
}
} else {
// Fallback
requestAnimationFrame(() => requestAnimationFrame(() => fetchUserSubmissions(tutorialTasks)));
}
});
});
} else {
}
} catch (error) {
console.error('[Trace] Delete:Exception', error);
}
// let DOM settle then unlock (both list and card if id known)
requestAnimationFrame(() => requestAnimationFrame(() => {
if (isSafari && taskId) {
// Hold locks until per-task refetch completes
pendingUnlocksRef.current.add(taskId);
setTimeout(() => {
api.get(`/api/submissions/by-source/${taskId}`).then(r => {
const list = (r.data && r.data.submissions) || [];
setUserSubmissions(prev => ({ ...prev, [taskId]: list }));
}).catch(() => {}).finally(() => {
withPreservedCardOffset(taskId, () => {
unlockListHeight();
unlockCardHeightById(taskId);
unlockGridHeightById(taskId);
if (SAFARI_FREEZE_ENABLED) unfreezeScroll();
pendingUnlocksRef.current.delete(taskId);
});
try {
const el = cardRefs.current[taskId];
const topAfter = el ? el.getBoundingClientRect().top : null;
console.log('[Trace] ScrollPreserve(del):after', { taskId, topAfter });
} catch {}
});
}, 300);
} else {
unlockListHeight();
if (taskId) {
unlockCardHeightById(taskId);
unlockGridHeightById(taskId);
}
if (isSafari && SAFARI_FREEZE_ENABLED) { unfreezeScroll(); }
}
setMutatingTaskId(null);
}));
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'approved':
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
case 'pending':
return <ClockIcon className="h-5 w-5 text-yellow-500" />;
default:
return <ClockIcon className="h-5 w-5 text-gray-500" />;
}
};
const startEditing = (task: TutorialTask) => {
setEditingTask(task._id);
setEditForm({
content: task.content,
translationBrief: task.translationBrief || '',
imageUrl: task.imageUrl || '',
imageAlt: task.imageAlt || ''
});
};
const startEditingBrief = () => {
setEditingBrief(prev => ({ ...prev, [selectedWeek]: true }));
setEditForm({
content: '',
translationBrief: tutorialWeek?.translationBrief || '',
imageUrl: '',
imageAlt: ''
});
};
const startAddingBrief = () => {
setEditingBrief(prev => ({ ...prev, [selectedWeek]: true }));
setEditForm({
content: '',
translationBrief: '',
imageUrl: '',
imageAlt: ''
});
};
const removeBrief = async () => {
try {
setSaving(true);
const token = localStorage.getItem('token');
const user = JSON.parse(localStorage.getItem('user') || '{}');
// Check if user is admin
if (user.role !== 'admin') {
return;
}
const response = await api.put(`/api/auth/admin/tutorial-brief/${selectedWeek}`, {
translationBrief: '',
weekNumber: selectedWeek
});
if (response.status >= 200 && response.status < 300) {
const briefKey = `translationBrief_week_${selectedWeek}`;
localStorage.removeItem(briefKey);
setEditForm((prev) => ({ ...prev, translationBrief: '' }));
await fetchTutorialTasks();
} else {
console.error('Failed to remove translation brief:', response.data);
}
} catch (error) {
console.error('Failed to remove translation brief:', error);
} finally {
setSaving(false);
}
};
const cancelEditing = () => {
setEditingTask(null);
setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
setEditForm({
content: '',
translationBrief: '',
imageUrl: '',
imageAlt: ''
});
setSelectedFile(null);
};
const saveTask = async () => {
if (!editingTask) return;
try {
setSaving(true);
const token = localStorage.getItem('token');
const user = JSON.parse(localStorage.getItem('user') || '{}');
// Check if user is admin
if (user.role !== 'admin') {
return;
}
const updateData = {
...editForm,
weekNumber: selectedWeek
};
console.log('Saving task with data:', updateData);
const response = await api.put(`/api/auth/admin/tutorial-tasks/${editingTask}`, updateData);
if (response.status >= 200 && response.status < 300) {
await fetchTutorialTasks(false);
setEditingTask(null);
} else {
console.error('Failed to update tutorial task:', response.data);
}
} catch (error) {
console.error('Failed to update tutorial task:', error);
} finally {
setSaving(false);
}
};
const saveBrief = async () => {
try {
setSaving(true);
const user = JSON.parse(localStorage.getItem('user') || '{}');
// Check if user is admin
if (user.role !== 'admin') {
return;
}
console.log('Saving brief for week:', selectedWeek);
console.log('Brief content:', editForm.translationBrief);
// Save brief by creating or updating the first task of the week
if (tutorialTasks.length > 0) {
const firstTask = tutorialTasks[0];
console.log('Updating first task with brief:', firstTask._id);
const response = await api.put(`/api/auth/admin/tutorial-tasks/${firstTask._id}`, {
...firstTask,
translationBrief: editForm.translationBrief,
weekNumber: selectedWeek
});
if (response.status >= 200 && response.status < 300) {
console.log('Brief saved successfully');
// Optimistic UI update
const briefKey = `translationBrief_week_${selectedWeek}`;
localStorage.setItem(briefKey, editForm.translationBrief);
setTutorialWeek(prev => prev ? { ...prev, translationBrief: editForm.translationBrief } : prev);
setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
// Background refresh
fetchTutorialTasks(false);
} else {
console.error('Failed to save brief:', response.data);
}
} else {
// If no tasks exist, save the brief to localStorage
console.log('No tasks available to save brief to - saving to localStorage');
const briefKey = `translationBrief_week_${selectedWeek}`;
localStorage.setItem(briefKey, editForm.translationBrief);
setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
}
} catch (error) {
console.error('Failed to update translation brief:', error);
} finally {
setSaving(false);
}
};
const startAddingTask = () => {
setAddingTask(true);
setEditForm({
content: '',
translationBrief: '',
imageUrl: '',
imageAlt: ''
});
};
const cancelAddingTask = () => {
setAddingTask(false);
setEditForm({
content: '',
translationBrief: '',
imageUrl: '',
imageAlt: ''
});
setSelectedFile(null);
};
const startAddingImage = () => {
setAddingImage(true);
setImageForm({
imageUrl: '',
imageAlt: '',
imageSize: 200,
imageAlignment: 'center'
});
};
const cancelAddingImage = () => {
setAddingImage(false);
setImageForm({
imageUrl: '',
imageAlt: '',
imageSize: 200,
imageAlignment: 'center'
});
};
const startAddingRevision = () => {
setAddingRevision(true);
};
const cancelAddingRevision = () => {
setAddingRevision(false);
};
const addRevision = () => {
setRevisionAdded(true);
setAddingRevision(false);
try {
localStorage.setItem(`tutorial_revision_added_week_${selectedWeek}`, 'true');
} catch (e) {
// Safari might block localStorage, but state is already updated
console.warn('Failed to save revision state to localStorage:', e);
}
};
const removeRevision = () => {
// Only allow removing if no tasks exist
if (!hasRevisionTasks) {
setRevisionAdded(false);
try {
localStorage.removeItem(`tutorial_revision_added_week_${selectedWeek}`);
} catch (e) {
// Safari might block localStorage, but state is already updated
console.warn('Failed to remove revision state from localStorage:', e);
}
} else {
// If tasks exist, warn the admin that they need to delete tasks first
alert('Cannot hide Deep Revision module while tasks exist. Please delete all tasks for this week first.');
}
};
const saveNewTask = async () => {
try {
setSaving(true);
const user = JSON.parse(localStorage.getItem('user') || '{}');
// Check if user is admin
if (user.role !== 'admin') {
return;
}
// Allow either content or imageUrl, but not both empty
if (!editForm.content.trim() && !editForm.imageUrl.trim()) {
return;
}
console.log('Saving new task for week:', selectedWeek);
console.log('Task content:', editForm.content);
console.log('Image URL:', editForm.imageUrl);
console.log('Image Alt:', editForm.imageAlt);
const taskData = {
title: `Week ${selectedWeek} Tutorial Task`,
content: editForm.content,
sourceLanguage: 'English',
weekNumber: selectedWeek,
category: 'tutorial',
imageUrl: editForm.imageUrl || null,
imageAlt: editForm.imageAlt || null,
// Add imageSize and imageAlignment for image-only content
...(editForm.imageUrl && !editForm.content.trim() && { imageSize: 200 }),
...(editForm.imageUrl && !editForm.content.trim() && { imageAlignment: 'center' })
};
console.log('Task data being sent:', JSON.stringify(taskData, null, 2));
console.log('Sending task data:', taskData);
const response = await api.post('/api/auth/admin/tutorial-tasks', taskData);
console.log('Task save response:', response.data);
if (response.status >= 200 && response.status < 300) {
console.log('Task saved successfully');
console.log('Saved task response:', response.data);
console.log('Saved task response keys:', Object.keys(response.data || {}));
console.log('Saved task response.task:', response.data?.task);
console.log('Saved task response.task.imageUrl:', response.data?.task?.imageUrl);
console.log('Saved task response.task.translationBrief:', response.data?.task?.translationBrief);
await fetchTutorialTasks(false);
setAddingTask(false);
} else {
console.error('Failed to add tutorial task:', response.data);
}
} catch (error) {
console.error('Failed to add tutorial task:', error);
} finally {
setSaving(false);
}
};
const saveNewImage = async () => {
try {
setSaving(true);
const user = JSON.parse(localStorage.getItem('user') || '{}');
// Check if user is admin
if (user.role !== 'admin') {
return;
}
if (!imageForm.imageUrl.trim()) {
return;
}
const payload = {
title: `Week ${selectedWeek} Image Task`,
content: '', // Empty content for image-only task
sourceLanguage: 'English',
weekNumber: selectedWeek,
category: 'tutorial',
imageUrl: imageForm.imageUrl.trim(),
imageAlt: imageForm.imageAlt.trim() || null,
imageSize: imageForm.imageSize,
imageAlignment: imageForm.imageAlignment
};
console.log('Saving new image task with payload:', payload);
const response = await api.post('/api/auth/admin/tutorial-tasks', payload);
if (response.data) {
console.log('Image task saved successfully:', response.data);
await fetchTutorialTasks(false);
setAddingImage(false);
} else {
console.error('Failed to save image task');
}
} catch (error) {
console.error('Failed to add image task:', error);
} finally {
setSaving(false);
}
};
const deleteTask = async (taskId: string) => {
try {
const token = localStorage.getItem('token');
const user = JSON.parse(localStorage.getItem('user') || '{}');
// Check if user is admin
if (user.role !== 'admin') {
return;
}
const response = await api.delete(`/api/auth/admin/tutorial-tasks/${taskId}`);
if (response.status >= 200 && response.status < 300) {
await fetchTutorialTasks(false);
} else {
console.error('Failed to delete tutorial task:', response.data);
}
await fetchTutorialTasks(false);
} catch (error) {
console.error('Failed to delete task:', error);
}
};
// Remove intrusive loading screen - just show content with loading state
const GroupInputs: React.FC<{
taskId: string;
label: string;
selectWidthClass: string;
selectedGroup: number | '';
onGroupChange: (n: number) => void;
translationValue: string;
onTranslationChange: (v: string) => void;
submitting: boolean;
onSubmit: () => void;
}> = React.memo(({ taskId, label, selectWidthClass, selectedGroup, onGroupChange, translationValue, onTranslationChange, submitting, onSubmit }) => {
return (
<>
<div className="mb-2">
<label className="block text-xs font-medium text-gray-700 mb-1">{label}</label>
<select
value={selectedGroup || ''}
onChange={(e) => {
const v = parseInt(e.target.value);
onGroupChange(Number.isNaN(v) ? ('' as any) : v);
}}
className={`${selectWidthClass} px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white text-xs`}
>
<option value="">Choose...</option>
{[1,2,3,4,5,6,7,8].map((g) => (<option key={g} value={g}>Group {g}</option>))}
</select>
</div>
<div className="flex items-center justify-end space-x-2 mb-2">
<button onClick={() => applyInlineFormat(`tutorial-translation-${taskId}`, translationValue || '', v => onTranslationChange(v), '**')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">B</button>
<button onClick={() => applyInlineFormat(`tutorial-translation-${taskId}`, translationValue || '', v => onTranslationChange(v), '*')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded italic">I</button>
<button onClick={() => applyLinkFormat(`tutorial-translation-${taskId}`, translationValue || '', v => onTranslationChange(v))} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">Link</button>
</div>
<textarea
id={`tutorial-translation-${taskId}`}
value={translationValue || ''}
onChange={(e) => {
onTranslationChange(e.target.value);
}}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
style={{ height: '150px' }}
rows={4}
placeholder="Enter your group's translation here..."
/>
<div className="flex justify-end mt-2">
<button onClick={onSubmit} disabled={submitting} className="btn-primary disabled:bg-gray-400 text-white px-4 py-2 rounded-lg text-sm">{submitting ? 'Submitting...' : 'Submit Translation'}</button>
</div>
</>
);
}, (a, b) => (
a.selectedGroup === b.selectedGroup &&
a.translationValue === b.translationValue &&
a.submitting === b.submitting &&
a.label === b.label &&
a.selectWidthClass === b.selectWidthClass &&
a.taskId === b.taskId
));
return (
<div className="min-h-screen bg-white py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center mb-4">
<img src="/icons/tutorial tasks.svg" alt="Tutorial Tasks" className="h-8 w-8 mr-3" />
<h1 className="text-3xl font-bold text-ui-text">Tutorial Tasks</h1>
</div>
<p className="text-ui-text/70">
Complete weekly tutorial tasks with your group to practice collaborative translation skills.
</p>
</div>
{/* Week Selector */}
<div className="mb-6">
<div className="flex space-x-3 overflow-x-auto pb-2">
{weeks.map((week) => {
const isActive = selectedWeek === week;
return (
<button
key={week}
onClick={() => handleWeekChange(week)}
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)]`}
style={{ background: 'rgba(255,255,255,0.10)' }}
>
{/* Rim washes */}
<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%)]" />
{/* Soft glossy wash */}
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
{/* Tiny hotspot near TL */}
<div className="pointer-events-none absolute rounded-full" style={{ width: '28px', height: '28px', left: '8px', top: '6px', background: 'radial-gradient(16px_16px_at_10px_10px,rgba(255,255,255,0.5),rgba(255,255,255,0)_60%)', opacity: 0.45 }} />
{/* Center darken for depth */}
{!isActive && (
<div className="pointer-events-none absolute inset-0 rounded-2xl" style={{ background: 'radial-gradient(120%_120%_at_50%_55%,rgba(0,0,0,0.04),rgba(0,0,0,0)_60%)', opacity: 0.9 }} />
)}
{/* Blue tint overlay to match brand */}
<div className={`pointer-events-none absolute inset-0 rounded-2xl ${isActive ? 'bg-sky-600/70 mix-blend-normal opacity-100' : 'bg-sky-500/30 mix-blend-overlay opacity-35'}`} />
<span className={`relative z-10 text-sm font-medium ${isActive ? 'text-white' : 'text-black'}`}>Week {week}</span>
</button>
);
})}
</div>
{isAdmin && (
<div className="mt-2 flex items-center gap-2">
<span className="text-xs text-ui-text/70">Visibility</span>
<label className="switch">
<input type="checkbox" checked={isWeekHidden} onChange={async(e)=>{
try {
const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/,'');
const nextHidden = e.currentTarget.checked;
await fetch(`${base}/api/admin/weeks/tutorial/${selectedWeek}/visibility`,{ method:'PUT', headers:{ 'Content-Type':'application/json', 'Authorization': localStorage.getItem('token')?`Bearer ${localStorage.getItem('token')}`:'', 'user-role':'admin' }, body: JSON.stringify({ hidden: nextHidden }) });
setIsWeekHidden(nextHidden);
await fetchTutorialTasks(true);
} catch (err) { console.error(err);}
}}/>
<span className="slider" />
</label>
<span className="text-xs">{isWeekHidden ? 'Hidden' : 'Shown'}</span>
</div>
)}
</div>
{/* Week Transition Loading Spinner */}
{isWeekTransitioning && (
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50">
<div className="bg-white rounded-lg shadow-lg p-4 flex items-center space-x-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-ui-neonBlue"></div>
<span className="text-gray-700 font-medium">Loading...</span>
</div>
</div>
)}
{!isWeekTransitioning && (
<>
{/* Translation Brief - Shown once at the top (hidden for students when week is hidden) */}
{tutorialWeek && tutorialWeek.translationBrief && (!isWeekHidden || (isWeekHidden && isAdmin)) ? (
<div ref={briefRef} className="bg-ui-panel rounded-lg p-6 mb-8 border border-ui-border shadow-sm">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="relative p-2 rounded-2xl 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)]">
<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" />
<div className="pointer-events-none absolute rounded-full" style={{ width: '20px', height: '20px', left: '6px', top: '6px', background: 'radial-gradient(12px_12px_at_10px_10px,rgba(255,255,255,0.5),rgba(255,255,255,0)_60%)', opacity: 0.45 }} />
<div className="pointer-events-none absolute inset-0 rounded-2xl" style={{ background: 'radial-gradient(120%_120%_at_50%_55%,rgba(0,0,0,0.04),rgba(0,0,0,0)_60%)', opacity: 0.9 }} />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-sky-500/30 mix-blend-overlay opacity-35" />
<DocumentTextIcon className="relative h-5 w-5 text-ui-text" />
</div>
<h3 className="text-ui-text font-semibold text-xl">Translation Brief</h3>
</div>
{JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
<div className="flex items-center space-x-2">
{editingBrief[selectedWeek] ? (
<>
<button
onClick={saveBrief}
disabled={saving}
className="relative inline-flex items-center gap-2 px-3 py-1 rounded-2xl text-sm font-medium text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 shadow-[inset_0_1px_0_rgba(255,255,255,0.6),inset_0_-1px_0_rgba(0,0,0,0.12)] bg-sky-600/70"
>
{saving ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
) : (
<CheckIcon className="h-4 w-4" />
)}
</button>
<button
onClick={cancelEditing}
className="relative inline-flex items-center gap-2 px-3 py-1 rounded-2xl text-sm font-medium text-black ring-1 ring-inset ring-white/30 backdrop-blur-md shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)] bg-white/10"
>
<XMarkIcon className="h-4 w-4" />
</button>
</>
) : (
<>
<button
onClick={startEditingBrief}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
>
<PencilIcon className="h-4 w-4" />
</button>
<button
onClick={() => removeBrief()}
className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
>
<TrashIcon className="h-4 w-4" />
</button>
</>
)}
</div>
)}
</div>
{editingBrief[selectedWeek] ? (
<div>
<div className="flex items-center justify-end space-x-2 mb-2">
<button onClick={() => applyInlineFormat('tutorial-brief-input', editForm.translationBrief, v => setEditForm({ ...editForm, translationBrief: v }), '**')} className="px-2 py-1 text-xs bg-blue-100 text-ui-neonBlue rounded">B</button>
<button onClick={() => applyInlineFormat('tutorial-brief-input', editForm.translationBrief, v => setEditForm({ ...editForm, translationBrief: v }), '*')} className="px-2 py-1 text-xs bg-blue-100 text-ui-neonBlue rounded italic">I</button>
<button onClick={() => applyLinkFormat('tutorial-brief-input', editForm.translationBrief || '', v => setEditForm({ ...editForm, translationBrief: v }))} className="px-2 py-1 text-xs bg-blue-100 text-ui-neonBlue rounded">Link</button>
</div>
<textarea id="tutorial-brief-input"
value={editForm.translationBrief}
onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })}
className="w-full p-4 border border-gray-300 rounded-lg text-gray-900 leading-relaxed text-base bg-white focus:ring-2 focus:ring-ui-neonBlue focus:border-ui-neonBlue"
rows={6}
placeholder="Enter translation brief..."
/>
</div>
) : (
<div className="text-ui-text leading-relaxed text-lg font-smiley whitespace-pre-wrap">{renderFormatted(tutorialWeek.translationBrief || '')}</div>
)}
{/* Group Google Doc (refined) */}
{(() => { const hasDocs = !!localStorage.getItem(`tutorial_group_${selectedWeek}`) || (Array.isArray((tutorialWeek as any)?.groupDocs) && (tutorialWeek as any).groupDocs.length>0); const vm=(localStorage.getItem('viewMode')||'auto'); return hasDocs || (vm!== 'student'); })() && (
<div ref={groupDocRef} className="mt-6 bg-ui-panel rounded-xl border border-ui-border shadow-sm">
<div className="px-6 pt-6">
<h4 className="text-2xl font-bold text-ui-text">Group Google Doc</h4>
<p className="mt-2 text-ui-text/70">Open or share each group's working document.</p>
</div>
<div className="px-6 pb-4 pt-4 border-t border-gray-100">
<GroupDocSection weekNumber={selectedWeek} />
</div>
</div>
)}
<div className="mt-4 p-3 bg-ui-bg rounded-lg">
<p className="text-ui-text text-sm">
<strong>Note:</strong> Tutorial tasks are completed as group submissions. Each group should collaborate to create a single translation per task.
</p>
</div>
</div>
) : (
// Show add brief button when no brief exists
JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
<div className="bg-ui-panel rounded-lg p-6 mb-8 border border-ui-border border-dashed shadow-sm">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="relative p-2 rounded-2xl 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)]">
<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" />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-sky-500/30 mix-blend-overlay opacity-35" />
<DocumentTextIcon className="relative h-5 w-5 text-ui-text" />
</div>
<h3 className="text-ui-text font-semibold text-xl">Translation Brief</h3>
</div>
<button
onClick={startAddingBrief}
className="relative inline-flex items-center gap-2 px-4 py-2 rounded-2xl text-sm font-medium text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 shadow-[inset_0_1px_0_rgba(255,255,255,0.6),inset_0_-1px_0_rgba(0,0,0,0.12)] bg-sky-600/70"
>
<PlusIcon className="h-5 w-5" />
<span className="font-medium">Add Brief</span>
</button>
</div>
{editingBrief[selectedWeek] && (
<div className="space-y-4">
<div className="flex items-center justify-end space-x-2">
<button onClick={() => applyInlineFormat('tutorial-brief-input', editForm.translationBrief, v => setEditForm({ ...editForm, translationBrief: v }), '**')} className="px-2 py-1 text-xs bg-blue-100 text-ui-neonBlue rounded">B</button>
<button onClick={() => applyInlineFormat('tutorial-brief-input', editForm.translationBrief, v => setEditForm({ ...editForm, translationBrief: v }), '*')} className="px-2 py-1 text-xs bg-blue-100 text-ui-neonBlue rounded italic">I</button>
<button onClick={() => applyLinkFormat('tutorial-brief-input', editForm.translationBrief || '', v => setEditForm({ ...editForm, translationBrief: v }))} className="px-2 py-1 text-xs bg-blue-100 text-ui-neonBlue rounded">Link</button>
</div>
<textarea
id="tutorial-brief-input"
value={editForm.translationBrief}
onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })}
className="w-full p-4 border border-ui-border rounded-lg text-ui-text leading-relaxed text-base bg-ui-panel focus:ring-2 focus:ring-ui-neonBlue focus:border-ui-neonBlue"
rows={6}
placeholder="Enter translation brief..."
/>
<div className="flex justify-end space-x-2">
<button
onClick={saveBrief}
disabled={saving}
className="bg-ui-neonBlue hover:bg-ui-neonBlue/90 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm"
>
{saving ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
) : (
<>
<CheckIcon className="h-5 w-5" />
<span className="font-medium">Save Brief</span>
</>
)}
</button>
<button
onClick={cancelEditing}
className="bg-gray-500 hover:bg-gray-600 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm"
>
<XMarkIcon className="h-5 w-5" />
<span className="font-medium">Cancel</span>
</button>
</div>
</div>
)}
</div>
)
)}
{/* Tutorial Tasks */}
<div className="space-y-6">
{/* Add New Tutorial Task Section */}
{(() => { const u = JSON.parse(localStorage.getItem('user') || '{}'); const vm = (localStorage.getItem('viewMode')||'auto'); return vm === 'student' ? false : u.role === 'admin'; })() && (
<div className="mb-8">
{addingTask ? (
<div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
<div className="flex items-center space-x-3 mb-4">
<div className="bg-gray-100 rounded-lg p-2">
<PlusIcon className="h-4 w-4 text-gray-600" />
</div>
<h4 className="text-gray-900 font-semibold text-lg">New Tutorial Task</h4>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Task Content *
</label>
<div className="flex items-center justify-end space-x-2 mb-2">
<button onClick={() => applyInlineFormat('tutorial-newtask-input', editForm.content, v => setEditForm({ ...editForm, content: v }), '**')} className="px-2 py-1 text-xs bg-blue-100 text-ui-neonBlue rounded">B</button>
<button onClick={() => applyInlineFormat('tutorial-newtask-input', editForm.content, v => setEditForm({ ...editForm, content: v }), '*')} className="px-2 py-1 text-xs bg-blue-100 text-ui-neonBlue rounded italic">I</button>
<button onClick={() => applyLinkFormat('tutorial-newtask-input', editForm.content || '', v => setEditForm({ ...editForm, content: v }))} className="px-2 py-1 text-xs bg-blue-100 text-ui-neonBlue rounded">Link</button>
</div>
<textarea
id="tutorial-newtask-input"
value={editForm.content}
onChange={(e) => setEditForm({ ...editForm, content: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-ui-neonBlue focus:border-ui-neonBlue bg-white"
rows={4}
placeholder="Enter tutorial task content..."
/>
</div>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Image URL (Optional)
</label>
<input
type="url"
value={editForm.imageUrl}
onChange={(e) => setEditForm({ ...editForm, imageUrl: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ui-neonBlue focus:border-ui-neonBlue"
placeholder="https://example.com/image.jpg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Image Alt Text (Optional)
</label>
<input
type="text"
value={editForm.imageAlt}
onChange={(e) => setEditForm({ ...editForm, imageAlt: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ui-neonBlue focus:border-ui-neonBlue"
placeholder="Description of the image"
/>
</div>
</div>
{/* File Upload Section - Only for Week 2+ */}
{selectedWeek >= 2 && (
<div className="border-t pt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Upload Local Image (Optional)
</label>
<div className="space-y-2">
<input
type="file"
accept="image/*"
onChange={handleFileChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ui-neonBlue focus:border-ui-neonBlue"
/>
{selectedFile && (
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">{selectedFile.name}</span>
<button
type="button"
onClick={async () => {
try {
const imageUrl = await handleFileUpload(selectedFile);
setEditForm({ ...editForm, imageUrl });
setSelectedFile(null);
} catch (error) {
console.error('Upload error:', error);
}
}}
disabled={uploading}
className="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white px-3 py-1 rounded text-sm"
>
{uploading ? 'Uploading...' : 'Upload'}
</button>
</div>
)}
</div>
</div>
)}
</div>
</div>
<div className="flex justify-end space-x-2 mt-4">
<button
onClick={saveNewTask}
disabled={saving}
className="bg-indigo-500 hover:bg-indigo-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
>
{saving ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
) : (
<>
<CheckIcon className="h-4 w-4" />
<span>Save Task</span>
</>
)}
</button>
<button
onClick={cancelAddingTask}
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
>
<XMarkIcon className="h-4 w-4" />
<span>Cancel</span>
</button>
</div>
</div>
) : addingImage ? (
<div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
<div className="flex items-center space-x-3 mb-4">
<div className="bg-blue-100 rounded-lg p-2">
<PlusIcon className="h-4 w-4 text-blue-600" />
</div>
<h4 className="text-blue-900 font-semibold text-lg">New Image Task</h4>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Image URL *
</label>
<input
type="url"
value={imageForm.imageUrl}
onChange={(e) => setImageForm({ ...imageForm, imageUrl: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="https://example.com/image.jpg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Image Alt Text (Optional)
</label>
<input
type="text"
value={imageForm.imageAlt}
onChange={(e) => setImageForm({ ...imageForm, imageAlt: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Description of the image"
/>
</div>
{/* File Upload Section */}
<div className="border-t pt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Upload Local Image (Optional)
</label>
<div className="space-y-2">
<input
type="file"
accept="image/*"
onChange={handleFileChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
{selectedFile && (
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">{selectedFile.name}</span>
<button
type="button"
onClick={async () => {
try {
const imageUrl = await handleFileUpload(selectedFile);
setImageForm({ ...imageForm, imageUrl });
setSelectedFile(null);
} catch (error) {
console.error('Upload error:', error);
}
}}
disabled={uploading}
className="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white px-3 py-1 rounded text-sm"
>
{uploading ? 'Uploading...' : 'Upload'}
</button>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Image Size
</label>
<select
value={imageForm.imageSize}
onChange={(e) => setImageForm({ ...imageForm, imageSize: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value={150}>150px</option>
<option value={200}>200px</option>
<option value={300}>300px</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Image Alignment
</label>
<select
value={imageForm.imageAlignment}
onChange={(e) => setImageForm({ ...imageForm, imageAlignment: e.target.value as 'left' | 'center' | 'right' })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
{selectedWeek >= 4 && <option value="portrait-split">Portrait Split (image left, text+input right)</option>}
</select>
</div>
</div>
</div>
<div className="flex justify-end space-x-2 mt-4">
<button
onClick={saveNewImage}
disabled={saving}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
>
{saving ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
) : (
<>
<CheckIcon className="h-4 w-4" />
<span>Save Image</span>
</>
)}
</button>
<button
onClick={cancelAddingImage}
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
>
<XMarkIcon className="h-4 w-4" />
<span>Cancel</span>
</button>
</div>
</div>
) : (
<div className="bg-white rounded-lg p-6 border border-gray-200 border-dashed shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="bg-gray-100 rounded-lg p-2">
<PlusIcon className="h-5 w-5 text-gray-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-indigo-900">Add New Tutorial Task</h3>
<p className="text-gray-600 text-sm">Create a new tutorial task for Week {selectedWeek}</p>
</div>
</div>
<div className="flex space-x-3">
<div className="flex space-x-3">
<button
onClick={startAddingTask}
className="relative inline-flex items-center gap-2 px-4 py-2 rounded-2xl text-sm font-medium text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 shadow-[inset_0_1px_0_rgba(255,255,255,0.6),inset_0_-1px_0_rgba(0,0,0,0.12)] bg-sky-600/70"
>
<PlusIcon className="h-5 w-5" />
<span className="font-medium">Add Task</span>
</button>
{selectedWeek >= 3 && (
<button
onClick={startAddingImage}
className="relative inline-flex items-center gap-2 px-4 py-2 rounded-2xl text-sm font-medium text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 shadow-[inset_0_1px_0_rgba(255,255,255,0.6),inset_0_-1px_0_rgba(0,0,0,0.12)] bg-sky-600/70"
>
<PlusIcon className="h-5 w-5" />
<span className="font-medium">Add Image</span>
</button>
)}
{selectedWeek >= 5 && (
<button
onClick={startAddingRevision}
className="relative inline-flex items-center gap-2 px-4 py-2 rounded-2xl text-sm font-medium text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 shadow-[inset_0_1px_0_rgba(255,255,255,0.6),inset_0_-1px_0_rgba(0,0,0,0.12)] bg-purple-600/70"
>
<PlusIcon className="h-5 w-5" />
<span className="font-medium">Add Revision</span>
</button>
)}
</div>
</div>
</div>
</div>
)}
</div>
)}
<div ref={listRef} style={{ overflowAnchor: 'none' }}>
{addingRevision ? (
<div className="bg-white rounded-xl shadow-lg border border-gray-100 p-8 mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="bg-purple-100 rounded-lg p-2">
<PlusIcon className="h-5 w-5 text-purple-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900">Add Deep Revision</h3>
</div>
<div className="flex items-center space-x-2">
<button
onClick={addRevision}
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
>
<CheckIcon className="h-4 w-4" />
<span>Add</span>
</button>
<button
onClick={cancelAddingRevision}
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
>
<XMarkIcon className="h-4 w-4" />
<span>Cancel</span>
</button>
</div>
</div>
<div className="border-t pt-4">
<div className="mx-auto w-full max-w-5xl">
<TutorialRefinity weekNumber={selectedWeek} />
</div>
</div>
</div>
) : null}
{revisionAdded ? (
<div className="bg-white rounded-xl shadow-lg border border-gray-100 p-8 mb-8 max-w-full overflow-x-hidden">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="bg-purple-100 rounded-lg p-2">
<PlusIcon className="h-5 w-5 text-purple-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900">Deep Revision</h3>
</div>
{JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
<button
onClick={removeRevision}
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
>
<XMarkIcon className="h-4 w-4" />
<span>Remove</span>
</button>
)}
</div>
<div className="border-t pt-4">
<div className="mx-auto w-full max-w-5xl">
<TutorialRefinity weekNumber={selectedWeek} />
</div>
</div>
</div>
) : null}
{tutorialTasks.length === 0 && !addingTask && !addingRevision && !revisionAdded ? (
<div className="text-center py-12">
<DocumentTextIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No tutorial tasks available
</h3>
<p className="text-gray-600">
Tutorial tasks for Week {selectedWeek} haven't been set up yet.
</p>
</div>
) : (
tutorialTasks.map((task) => (
<div
key={task._id}
ref={(el) => { (cardRefs.current[task._id] = el); }}
className="bg-white rounded-xl shadow-lg border border-gray-100 p-8 hover:shadow-xl transition-shadow duration-300 mb-8"
style={{
minHeight: '700px',
overflowAnchor: mutatingTaskId === task._id ? 'none' as any : undefined
}}
>
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="relative p-2 rounded-2xl 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)]">
<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" />
<div className="pointer-events-none absolute rounded-full" style={{ width: '20px', height: '20px', left: '6px', top: '6px', background: 'radial-gradient(12px_12px_at_10px_10px,rgba(255,255,255,0.5),rgba(255,255,255,0)_60%)', opacity: 0.45 }} />
<div className="pointer-events-none absolute inset-0 rounded-2xl" style={{ background: 'radial-gradient(120%_120%_at_50%_55%,rgba(0,0,0,0.04),rgba(0,0,0,0)_60%)', opacity: 0.9 }} />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-sky-500/30 mix-blend-overlay opacity-35" />
<DocumentTextIcon className="relative h-5 w-5 text-ui-text" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
Source Text #{tutorialTasks.indexOf(task) + 1}
{JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && selectedWeek >= 4 && (
<span className="ml-2 inline-flex items-center space-x-1">
<button
className="px-2 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200"
onClick={() => moveTask(task._id, 'up')}
title="Move up"
>↑</button>
<button
className="px-2 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200"
onClick={() => moveTask(task._id, 'down')}
title="Move down"
>↓</button>
</span>
)}
</h3>
</div>
</div>
{JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
<div className="flex items-center space-x-2">
{editingTask === task._id ? (
<>
<button
onClick={saveTask}
disabled={saving}
className="bg-green-100 hover:bg-green-200 text-green-700 px-3 py-1 rounded-lg transition-colors duration-200"
>
{saving ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600"></div>
) : (
<CheckIcon className="h-4 w-4" />
)}
</button>
<button
onClick={cancelEditing}
className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
>
<XMarkIcon className="h-4 w-4" />
</button>
</>
) : (
<>
<button
onClick={() => startEditing(task)}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
>
<PencilIcon className="h-4 w-4" />
</button>
<button
onClick={() => deleteTask(task._id)}
className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
>
<TrashIcon className="h-4 w-4" />
</button>
</>
)}
</div>
)}
</div>
{/* Content - Gradient underlay + glass overlay */}
<div className="relative rounded-xl mb-6 p-0 border border-indigo-200">
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-sky-200/60 via-blue-200/50 to-indigo-200/60" />
<div className="relative rounded-xl bg-white/10 backdrop-blur-md ring-1 ring-inset ring-white/30 shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)] p-6">
<div className="pointer-events-none absolute inset-0 rounded-xl opacity-50 [background:linear-gradient(to_bottom,rgba(255,255,255,0.3),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.28),rgba(255,255,255,0)_28%)]" />
<div className="pointer-events-none absolute inset-0 rounded-xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-45" />
<div className="pointer-events-none absolute inset-0 rounded-xl" style={{ background: 'radial-gradient(120% 120% at 50% 55%, rgba(0,0,0,0.03), rgba(0,0,0,0) 60%)' }} />
<div className="pointer-events-none absolute inset-0 rounded-xl bg-sky-500/10 mix-blend-overlay opacity-30" />
{editingTask === task._id ? (
<div className="space-y-4">
<div className="flex items-center justify-end space-x-2 mb-2">
<button onClick={() => applyInlineFormat('tutorial-newtask-input', editForm.content, v => setEditForm({ ...editForm, content: v }), '**')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">B</button>
<button onClick={() => applyInlineFormat('tutorial-newtask-input', editForm.content, v => setEditForm({ ...editForm, content: v }), '*')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded italic">I</button>
<button onClick={() => applyLinkFormat('tutorial-newtask-input', editForm.content || '', v => setEditForm({ ...editForm, content: v }))} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">Link</button>
</div>
<textarea
id="tutorial-newtask-input"
value={editForm.content}
onChange={(e) => setEditForm({...editForm, content: e.target.value})}
className="w-full px-4 py-3 border border-indigo-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
rows={5}
placeholder="Enter source text..."
/>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Image URL</label>
<input
type="url"
value={editForm.imageUrl}
onChange={(e) => setEditForm({...editForm, imageUrl: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ui-neonBlue focus:border-ui-neonBlue"
placeholder="https://example.com/image.jpg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Image Alt Text</label>
<input
type="text"
value={editForm.imageAlt}
onChange={(e) => setEditForm({...editForm, imageAlt: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ui-neonBlue focus:border-ui-neonBlue"
placeholder="Description of the image"
/>
</div>
</div>
{/* File Upload Section - Only for Week 2+ */}
{selectedWeek >= 2 && (
<div className="border-t pt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Upload Local Image (Optional)
</label>
<div className="space-y-2">
<input
type="file"
accept="image/*"
onChange={handleFileChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ui-neonBlue focus:border-ui-neonBlue"
/>
{selectedFile && (
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">{selectedFile.name}</span>
<button
type="button"
onClick={async () => {
try {
const imageUrl = await handleFileUpload(selectedFile);
console.log('Uploaded image URL:', imageUrl);
setEditForm({ ...editForm, imageUrl });
console.log('Updated editForm:', { ...editForm, imageUrl });
// Save the task with the new image URL
if (editingTask) {
console.log('Saving task with image URL:', imageUrl);
const response = await api.put(`/api/auth/admin/tutorial-tasks/${editingTask}`, {
...editForm,
imageUrl,
weekNumber: selectedWeek
});
console.log('Task save response:', response.data);
if (response.status >= 200 && response.status < 300) {
await fetchTutorialTasks(false); // Refresh tasks
}
}
setSelectedFile(null);
} catch (error) {
console.error('Upload error:', error);
}
}}
disabled={uploading}
className="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white px-3 py-1 rounded text-sm"
>
{uploading ? 'Uploading...' : 'Upload'}
</button>
</div>
)}
</div>
</div>
)}
</div>
</div>
) : (
<div className="space-y-4">
{task.imageUrl ? (
task.imageAlignment === 'portrait-split' && selectedWeek >= 4 ? (
// Portrait split layout
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-start">
<div className="w-full flex justify-center">
<div className="inline-block rounded-lg shadow-md overflow-hidden">
<img src={task.imageUrl} alt={task.imageAlt || 'Uploaded image'} className="w-auto h-auto" style={{ maxHeight: '520px', maxWidth: '100%', objectFit: 'contain' }} />
</div>
</div>
<div className="w-full">
<div className="bg-indigo-50 rounded-lg p-4 mb-4 border border-indigo-200">
<h5 className="text-indigo-900 font-semibold mb-2">Source Text (from image)</h5>
<div
id={`tutorial-source-${task._id}`}
className="text-blue-800 leading-relaxed text-lg font-source-text whitespace-pre-wrap"
>{renderFormatted(task.content)}</div>
</div>
{localStorage.getItem('token') && (
<div className="bg-white rounded-lg p-4 border border-gray-200">
<h5 className="text-gray-900 font-semibold mb-2">Your Group's Translation</h5>
{/* Group selection (same as bottom block), shown here for portrait-split */}
<div className="mb-2">
<label className="block text-xs font-medium text-gray-700 mb-1">Select Your Group</label>
<select
value={selectedGroups[task._id] || ''}
onChange={(e) => {
const v = parseInt(e.target.value);
setSelectedGroups({ ...selectedGroups, [task._id]: Number.isNaN(v) ? ('' as any) : v });
}}
className="w-40 px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white text-xs"
>
<option value="">Choose...</option>
{[1,2,3,4,5,6,7,8].map((g) => (<option key={g} value={g}>Group {g}</option>))}
</select>
</div>
<div className="flex items-center justify-end space-x-2 mb-2">
<button onClick={() => applyInlineFormat(`tutorial-translation-${task._id}`, translationText[task._id] || '', v => setTranslationText({ ...translationText, [task._id]: v }), '**')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">B</button>
<button onClick={() => applyInlineFormat(`tutorial-translation-${task._id}`, translationText[task._id] || '', v => setTranslationText({ ...translationText, [task._id]: v }), '*')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded italic">I</button>
<button onClick={() => applyLinkFormat(`tutorial-translation-${task._id}`, translationText[task._id] || '', v => setTranslationText({ ...translationText, [task._id]: v }))} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">Link</button>
</div>
<textarea
id={`tutorial-translation-${task._id}`}
value={translationText[task._id] || ''}
onChange={(e) => {
const value = e.target.value;
setTranslationText({ ...translationText, [task._id]: value });
}}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
style={{ height: '150px' }}
rows={4}
placeholder="Enter your group's translation here..."
/>
<div className="flex justify-end mt-2">
<button onClick={() => handleSubmitTranslation(task._id)} disabled={submitting[task._id]} className="btn-primary disabled:bg-gray-400 text-white px-4 py-2 rounded-lg text-sm">{submitting[task._id] ? 'Submitting...' : 'Submit Translation'}</button>
</div>
</div>
)}
</div>
</div>
) : task.content === 'Image-based task' ? (
// Image-only layout with dynamic sizing and alignment
<div className={`flex flex-col md:flex-row gap-6 items-start ${
task.imageAlignment === 'left' ? 'md:flex-row' :
task.imageAlignment === 'right' ? 'md:flex-row-reverse' :
'md:flex-col'
}`}>
{/* Image section */}
<div className={`${
task.imageAlignment === 'left' ? 'w-full md:w-1/2' :
task.imageAlignment === 'right' ? 'w-full md:w-1/2' :
'w-full'
} flex ${
task.imageAlignment === 'left' ? 'justify-start' :
task.imageAlignment === 'right' ? 'justify-end' :
'justify-center'
}`}>
<div className="inline-block rounded-lg shadow-md overflow-hidden">
<img src={task.imageUrl} alt={task.imageAlt || 'Uploaded image'} className="w-full h-auto" style={{ height: `${task.imageSize || 200}px`, width: 'auto', objectFit: 'contain' }} onError={(e) => { console.error('Image failed to load:', e); (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
{task.imageAlt && (<div className="text-xs text-gray-500 mt-2 text-center">Alt: {task.imageAlt}</div>)}
</div>
</div>
</div>
) : (
// Regular task layout - use grid for stability like image-only layout
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-start">
<div className="w-full flex justify-center">
{task.imageUrl && task.imageUrl.startsWith('data:') ? (
<div className="inline-block rounded-lg shadow-md overflow-hidden">
<img src={task.imageUrl} alt={task.imageAlt || 'Uploaded image'} className="w-full h-auto" style={{ height: '200px', width: 'auto', objectFit: 'contain' }} onError={(e) => { console.error('Image failed to load:', e); (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
</div>
) : task.imageUrl ? (
<div className="inline-block rounded-lg shadow-md bg-green-500 text-white p-6 text-center">
<div className="text-3xl mb-2">📷</div>
<div className="font-semibold">Image Uploaded</div>
<div className="text-sm opacity-75">{task.imageUrl}</div>
</div>
) : (
<div className="w-full h-48 bg-gray-100 rounded-lg flex items-center justify-center">
<div className="text-gray-500">No image</div>
</div>
)}
</div>
<div className="w-full">
<div className="text-indigo-900 leading-relaxed text-lg font-source-text whitespace-pre-wrap">{renderFormatted(task.content)}</div>
</div>
</div>
)
) : (
// Text only when no image
<div>
<div className="text-indigo-900 leading-relaxed text-lg font-source-text whitespace-pre-wrap">{renderFormatted(task.content)}</div>
</div>
)}
{(() => { console.log('Task imageUrl check:', task._id, task.imageUrl, !!task.imageUrl); return null; })()}
</div>
)}
</div>
</div>
</div>
{/* All Submissions for this Task */}
{userSubmissions[task._id] && userSubmissions[task._id].length > 0 && (
<div ref={(el) => { submissionsContainerRefs.current[task._id] = el; }} className="bg-gradient-to-r from-white to-indigo-50 rounded-xl p-6 mb-6 border border-stone-200" style={{ overflowAnchor: 'none' }}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<div className="bg-indigo-100 rounded-full p-1">
<CheckCircleIcon className="h-4 w-4 text-indigo-900" />
</div>
<h4 className="text-stone-900 font-semibold text-lg">All Submissions ({userSubmissions[task._id].length})</h4>
</div>
<button
onClick={() => toggleExpanded(task._id)}
className="flex items-center space-x-1 text-indigo-900 hover:text-indigo-900 text-sm font-medium"
>
<span>{expandedSections[task._id] ? 'Collapse' : 'Expand'}</span>
<svg
className={`w-4 h-4 transition-transform duration-200 ${expandedSections[task._id] ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div ref={(el) => { submissionsGridRefs.current[task._id] = el; }} className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 ${
expandedSections[task._id]
? 'max-h-none overflow-visible'
: 'max-h-0 overflow-hidden'
}`} data-grid-id={task._id}>
{userSubmissions[task._id].map((submission, index) => (
<div key={submission._id} className="bg-white rounded-lg p-3 border border-stone-200 flex flex-col justify-between h-full">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
{submission.isOwner && (
<span className="inline-block bg-purple-100 text-purple-800 text-xs px-1.5 py-0.5 rounded-full">
Your Submission
</span>
)}
</div>
{getStatusIcon(submission.status)}
</div>
<p className="text-stone-800 leading-relaxed text-base mb-2 font-smiley whitespace-pre-wrap break-words">{renderFormatted(submission.transcreation || '')}</p>
<div className="flex items-center space-x-4 text-xs text-stone-700 mt-auto">
<div className="flex items-center space-x-1">
<span className="font-medium">Group:</span>
<span className="bg-indigo-100 px-1.5 py-0.5 rounded-full text-indigo-900 text-xs">
{submission.groupNumber}
</span>
</div>
<div className="flex items-center space-x-1">
<span className="font-medium">Votes:</span>
<span className="bg-indigo-100 px-1.5 py-0.5 rounded-full text-indigo-900 text-xs">
{(submission.voteCounts?.['1'] || 0) + (submission.voteCounts?.['2'] || 0) + (submission.voteCounts?.['3'] || 0)}
</span>
</div>
</div>
<div className="flex items-center space-x-2 mt-2">
{(submission.isOwner || (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin')) && (
<button
onClick={() => handleEditSubmission(submission._id, submission.transcreation)}
className="text-indigo-900 hover:text-indigo-900 text-sm font-medium"
>
Edit
</button>
)}
{JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
<button
onClick={() => handleDeleteSubmission(submission._id, task._id)}
className="text-red-600 hover:text-red-800 text-sm font-medium"
>
Delete
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Translation Input (always show if user is logged in, but hide for image-only content) */}
{localStorage.getItem('token') && task.content !== 'Image-based task' && (
<TranslationSection
taskId={task._id}
selectedGroup={selectedGroups[task._id] || ''}
translation={translationText[task._id] || ''}
submitting={!!submitting[task._id]}
onGroupChange={(n) => setSelectedGroups(prev => ({ ...prev, [task._id]: n as number }))}
onTranslationChange={(v) => setTranslationText(prev => ({ ...prev, [task._id]: v }))}
onSubmit={(localText, localGroup) => handleSubmitTranslation(task._id, localText, localGroup)}
label="Select Your Group"
selectWidthClass="w-40"
containerClassName="bg-white rounded-lg p-4 border border-gray-200"
/>
)}
{/* Show login message for visitors */}
{!localStorage.getItem('token') && (
<div className="relative rounded-xl p-6 border border-gray-200">
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-gray-100/70 to-indigo-100/60" />
<div className="relative rounded-xl bg-white/10 backdrop-blur-md ring-1 ring-inset ring-white/30 shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)] p-6">
<div className="pointer-events-none absolute inset-0 rounded-xl opacity-50 [background:linear-gradient(to_bottom,rgba(255,255,255,0.3),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.28),rgba(255,255,255,0)_28%)]" />
<div className="flex items-center space-x-2 mb-4">
<div className="relative p-1.5 rounded-2xl 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)]">
<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%)]" />
<DocumentTextIcon className="relative h-4 w-4 text-gray-700" />
</div>
<h4 className="text-gray-900 font-semibold text-lg">Login Required</h4>
</div>
<p className="text-gray-700 mb-4">
Please log in to submit translations for this tutorial task.
</p>
<button onClick={() => window.location.href = '/login'} className="relative inline-flex items-center justify-center gap-2 px-4 py-2 rounded-2xl text-sm font-medium text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 shadow-[inset_0_1px_0_rgba(255,255,255,0.6),inset_0_-1px_0_rgba(0,0,0,0.12)] bg-sky-600/70 transition-all duration-200">
Go to Login
<ArrowRightIcon className="h-4 w-4 ml-2" />
</button>
</div>
</div>
)}
</div>
))
)}
</div>
</div>
</>
)}
</div>
{/* Edit Submission Modal */}
{editingSubmission && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Edit Translation</h3>
<button
onClick={cancelEditSubmission}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<div className="mb-4">
<textarea
value={editSubmissionText}
onChange={(e) => setEditSubmissionText(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
rows={6}
placeholder="Enter your translation..."
/>
</div>
<div className="flex justify-end space-x-3">
<button
onClick={cancelEditSubmission}
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg"
>
Cancel
</button>
<button
onClick={saveEditedSubmission}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Save Changes
</button>
</div>
</div>
</div>
)}
</div>
);
};
const TranslationSection: React.FC<{
taskId: string;
selectedGroup: number | '';
translation: string;
submitting: boolean;
onGroupChange: (n: number) => void;
onTranslationChange: (v: string) => void;
onSubmit: (localText: string, localGroup: number) => void;
label?: string;
selectWidthClass?: string;
containerClassName?: string;
}> = React.memo(({ taskId, selectedGroup, translation, submitting, onGroupChange, onTranslationChange, onSubmit, label = 'Select Your Group *', selectWidthClass = 'w-48', containerClassName = 'bg-white rounded-lg p-6 border border-gray-200 shadow-sm safari-stable-form' }) => {
const [localText, setLocalText] = React.useState<string>(translation || '');
const [localGroup, setLocalGroup] = React.useState<number | ''>(selectedGroup || '');
React.useEffect(() => { setLocalText(translation || ''); }, [taskId]);
React.useEffect(() => { setLocalGroup(selectedGroup || ''); }, [taskId, selectedGroup]);
const isSafariEnv = typeof navigator !== 'undefined' && /Safari\//.test(navigator.userAgent) && !/Chrome\//.test(navigator.userAgent) && !/CriOS\//.test(navigator.userAgent);
const submitTriggeredRef = React.useRef(false);
const triggerSubmitOnce = React.useCallback((e?: React.SyntheticEvent) => {
if (submitTriggeredRef.current) { e?.preventDefault?.(); return; }
submitTriggeredRef.current = true;
if (localGroup) onSubmit(localText, localGroup as number);
// Small cooldown to avoid duplicate fire from click after pointerdown
setTimeout(() => { submitTriggeredRef.current = false; }, 400);
}, [localText, localGroup, onSubmit]);
const wrapSelection = (wrapper: '**' | '*') => {
const el = document.getElementById(`tutorial-translation-${taskId}`) as HTMLTextAreaElement | null;
const current = localText || '';
if (!el) { const next = current + wrapper + wrapper; setLocalText(next); return; }
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? current.length;
const before = current.slice(0, start);
const selection = current.slice(start, end);
const after = current.slice(end);
const next = `${before}${wrapper}${selection}${wrapper}${after}`;
setLocalText(next);
setTimeout(() => { try { el.focus(); el.setSelectionRange(before.length + wrapper.length + selection.length + wrapper.length, before.length + wrapper.length + selection.length + wrapper.length); } catch {} }, 0);
};
const insertLink = () => {
const urlRaw = window.prompt('Enter URL', 'https://') || '';
let url = urlRaw.trim();
if (!url) return;
url = url.replace(/["'>)\s]+$/g, '');
const el = document.getElementById(`tutorial-translation-${taskId}`) as HTMLTextAreaElement | null;
const current = localText || '';
if (!el) { const next = `${current}[link](${url})`; setLocalText(next); return; }
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? current.length;
const before = current.slice(0, start);
const sel = current.slice(start, end) || 'link';
const after = current.slice(end);
const next = `${before}[${sel}](${url})${after}`;
setLocalText(next);
setTimeout(() => { try { el.focus(); const newPos = before.length + sel.length + 4 + url.length + 2; el.setSelectionRange(newPos, newPos); } catch {} }, 0);
};
return (
<div className={containerClassName}>
<div className="flex items-center space-x-3 mb-4">
<div className="bg-gray-100 rounded-lg p-2">
<DocumentTextIcon className="h-4 w-4 text-gray-600" />
</div>
<h4 className="text-gray-900 font-semibold text-lg">Group Translation</h4>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">{label}</label>
<select
value={localGroup || ''}
onChange={(e) => {
const value = parseInt(e.target.value);
const next = Number.isNaN(value) ? ('' as any) : value;
setLocalGroup(next);
// Parent sync deferred to submit; no immediate parent state update to minimize re-renders
}}
className={`${selectWidthClass} 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`}
required
>
<option value="">Choose your group...</option>
{[1, 2, 3, 4, 5, 6, 7, 8].map((group) => (
<option key={group} value={group}>Group {group}</option>
))}
</select>
</div>
<div className="flex items-center justify-end space-x-2 mb-2">
<button onClick={() => wrapSelection('**')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">B</button>
<button onClick={() => wrapSelection('*')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded italic">I</button>
<button onClick={insertLink} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">Link</button>
</div>
<textarea
id={`tutorial-translation-${taskId}`}
value={localText || ''}
onChange={(e) => { setLocalText(e.target.value); }}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
style={{ height: '150px' }}
rows={4}
placeholder="Enter your group's translation here..."
/>
<button
type="button"
onClick={!isSafariEnv ? (e) => { e.preventDefault(); triggerSubmitOnce(e); } : undefined}
onPointerDown={isSafariEnv ? (e) => { e.preventDefault(); triggerSubmitOnce(e as unknown as React.SyntheticEvent); } : undefined}
disabled={submitting}
className="relative inline-flex items-center justify-center gap-2 px-4 py-2 rounded-2xl text-sm font-medium text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 shadow-[inset_0_1px_0_rgba(255,255,255,0.6),inset_0_-1px_0_rgba(0,0,0,0.12)] bg-sky-600/70 disabled:bg-gray-400 mt-2"
style={{ touchAction: 'manipulation' as any }}
>
{submitting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Submitting...</span>
</>
) : (
<>
<CheckIcon className="h-4 w-4" />
<span>Submit Translation</span>
</>
)}
</button>
</div>
);
}, (a, b) => (
a.selectedGroup === b.selectedGroup &&
a.translation === b.translation &&
a.submitting === b.submitting &&
a.taskId === b.taskId
));
export default TutorialTasks;