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(() => { const savedWeek = localStorage.getItem('selectedTutorialWeek'); return savedWeek ? parseInt(savedWeek) : 1; }); const [isWeekTransitioning, setIsWeekTransitioning] = useState(false); const [tutorialTasks, setTutorialTasks] = useState([]); const [tutorialWeek, setTutorialWeek] = useState(null); const [userSubmissions, setUserSubmissions] = useState<{[key: string]: UserSubmission[]}>({}); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState<{[key: string]: boolean}>({}); const [isWeekHidden, setIsWeekHidden] = useState(false); const [translationText, setTranslationText] = useState<{[key: string]: string}>({}); const [mutatingTaskId, setMutatingTaskId] = useState(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(null); const groupDocRef = useRef(null); const listRef = useRef(null); const submissionsGridRefs = useRef<{[key: string]: HTMLDivElement | null}>({}); const submissionsContainerRefs = useRef<{[key: string]: HTMLDivElement | null}>({}); const withPreservedScroll = useRef<(fn: () => void) => void>(); const pendingUnlocksRef = useRef>(new Set()); const lastPreHeightRef = useRef<{[key: string]: number}>({}); const disableCompensationRef = useRef>(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(null); const [group, setGroup] = useState(() => { const saved = localStorage.getItem(`tutorial_group_${weekNumber}`); return saved ? parseInt(saved) : 1; }); const [creating, setCreating] = useState(false); const [docs, setDocs] = useState([]); const [urlInput, setUrlInput] = useState(''); const [errorMsg, setErrorMsg] = useState(''); const [copiedLink, setCopiedLink] = useState(''); 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 }) => ( ); 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 (
{/* Top control row */} {isAdmin && (
)} {/* Replace / Add link inline editor */} {isAdmin && (
{ 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" />
{errorMsg &&
{errorMsg}
}
)} {/* All groups table */}
All Groups
{docs.length === 0 && ( )} {docs.map(d => ( ))}
Group Actions
No group docs yet.
Group {d.groupNumber}
Open {isAdmin && ( <> )}
); }; // Basic inline formatting helpers (bold/italic via simple markdown) for weeks 4–6 const renderFormatted = (text: string) => { const escape = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); // 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, '$1') // Plain URLs with protocol, avoid matching inside attributes (require a non-attribute preceding char) .replace(/(^|[^=\"'\/:])(https?:\/\/[^\s<]+)/g, (m, p1, url) => `${p1}${url}`) // www.* domains (prepend https://), also avoid attributes .replace(/(^|[^=\"'\/:])(www\.[^\s<]+)/g, (m, p1, host) => `${p1}${host}`) .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\*(.+?)\*/g, '$1') .replace(/\n/g, '
'); return ; }; 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(null); const [editingBrief, setEditingBrief] = useState<{[key: number]: boolean}>({}); const [addingTask, setAddingTask] = useState(false); const [addingImage, setAddingImage] = useState(false); const [addingRevision, setAddingRevision] = useState(false); const [revisionAdded, setRevisionAdded] = useState(false); const [hasRevisionTasks, setHasRevisionTasks] = useState(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(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 => { 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) => { 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 ; case 'pending': return ; default: return ; } }; 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 ( <>