|
|
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'; |
|
|
|
|
|
|
|
|
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 {} |
|
|
}; |
|
|
|
|
|
|
|
|
const scrollLockState = useRef<{ y: number } | null>(null); |
|
|
const freezeScroll = () => { |
|
|
try { |
|
|
if (scrollLockState.current) return; |
|
|
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 {} |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const isSafari = typeof navigator !== 'undefined' && /Safari\//.test(navigator.userAgent) && !/Chrome\//.test(navigator.userAgent) && !/CriOS\//.test(navigator.userAgent); |
|
|
const SAFARI_FREEZE_ENABLED = false; |
|
|
|
|
|
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 {} |
|
|
}; |
|
|
|
|
|
|
|
|
const lockContainerHeightById = (_id: string) => {}; |
|
|
const unlockContainerHeightById = (_id: string) => {}; |
|
|
|
|
|
|
|
|
|
|
|
const withPreservedCardOffset = (taskId: string, fn: () => void) => { |
|
|
|
|
|
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 {} |
|
|
|
|
|
window.scrollBy(0, -clampedDelta); |
|
|
} |
|
|
} else { |
|
|
window.scrollTo(0, scrollYBefore); |
|
|
} |
|
|
}); |
|
|
}; |
|
|
|
|
|
|
|
|
useLayoutEffect(() => { |
|
|
withPreservedScroll.current = (fn: () => void) => { |
|
|
try { |
|
|
const y = window.scrollY; |
|
|
fn(); |
|
|
|
|
|
requestAnimationFrame(() => { |
|
|
requestAnimationFrame(() => { |
|
|
requestAnimationFrame(() => { |
|
|
window.scrollTo(0, y); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
} catch { |
|
|
fn(); |
|
|
} |
|
|
}; |
|
|
}, []); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
const normalized = current.map((t, i) => ({ id: t._id, position: i })); |
|
|
|
|
|
const posA = normalized[index].position; |
|
|
const posB = normalized[targetIndex].position; |
|
|
normalized[index].position = posB; |
|
|
normalized[targetIndex].position = posA; |
|
|
|
|
|
|
|
|
|
|
|
setTutorialTasks((prev) => { |
|
|
const next = [...prev]; |
|
|
|
|
|
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; |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
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 }) |
|
|
]); |
|
|
|
|
|
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 { |
|
|
|
|
|
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); |
|
|
|
|
|
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> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const renderFormatted = (text: string) => { |
|
|
const escape = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
|
|
|
|
|
const html = escape(text) |
|
|
|
|
|
.replace(/\[([^\]]+?)\]\((https?:\/\/[^\s)]+)\)/g, '<a class="text-indigo-600 underline" href="$2" target="_blank" rel="noopener noreferrer">$1</a>') |
|
|
|
|
|
.replace(/(^|[^=\"'\/:])(https?:\/\/[^\s<]+)/g, (m, p1, url) => `${p1}<a class="text-indigo-600 underline" href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`) |
|
|
|
|
|
.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; |
|
|
|
|
|
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}`); |
|
|
|
|
|
setTimeout(() => { |
|
|
el.focus(); |
|
|
const newPos = before.length + selection.length + 4 + url.length + 2; |
|
|
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); |
|
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
const checkForRevisionTasks = async () => { |
|
|
if (selectedWeek < 5) { |
|
|
setHasRevisionTasks(false); |
|
|
return; |
|
|
} |
|
|
try { |
|
|
|
|
|
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 (hasTasks) { |
|
|
setRevisionAdded(true); |
|
|
|
|
|
try { |
|
|
localStorage.setItem(`tutorial_revision_added_week_${selectedWeek}`, 'true'); |
|
|
} catch (e) { |
|
|
|
|
|
} |
|
|
} |
|
|
} catch (e) { |
|
|
console.warn('Failed to check for revision tasks:', e); |
|
|
setHasRevisionTasks(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
checkForRevisionTasks(); |
|
|
}, [selectedWeek]); |
|
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
const checkRevisionState = () => { |
|
|
try { |
|
|
const saved = localStorage.getItem(`tutorial_revision_added_week_${selectedWeek}`); |
|
|
const isAdded = saved === 'true'; |
|
|
|
|
|
if (!hasRevisionTasks) { |
|
|
setRevisionAdded(isAdded); |
|
|
} |
|
|
} catch (e) { |
|
|
|
|
|
if (!hasRevisionTasks) { |
|
|
setRevisionAdded(false); |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
checkRevisionState(); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
setTutorialTasks([]); |
|
|
setTutorialWeek(null); |
|
|
setUserSubmissions({}); |
|
|
|
|
|
|
|
|
setSelectedWeek(week); |
|
|
localStorage.setItem('selectedTutorialWeek', week.toString()); |
|
|
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 50)); |
|
|
|
|
|
|
|
|
try { |
|
|
|
|
|
let response; |
|
|
try { |
|
|
response = await api.get(`/api/search/tutorial-tasks/${week}`); |
|
|
} catch (e) { |
|
|
|
|
|
response = await api.get(`/api/search/tutorial-tasks/${week}`); |
|
|
} |
|
|
|
|
|
if (response.data) { |
|
|
const tasks = response.data; |
|
|
console.log('Fetched tasks for week', week, ':', tasks); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
await fetchUserSubmissions(tasks); |
|
|
} |
|
|
|
|
|
|
|
|
const delay = week === 2 ? 400 : 200; |
|
|
await new Promise(resolve => setTimeout(resolve, delay)); |
|
|
} catch (error) { |
|
|
console.error('Error loading week data:', error); |
|
|
} finally { |
|
|
|
|
|
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) { } |
|
|
}; |
|
|
loadVisibility(); |
|
|
}, [selectedWeek]); |
|
|
|
|
|
const handleFileUpload = async (file: File): Promise<string> => { |
|
|
try { |
|
|
setUploading(true); |
|
|
|
|
|
|
|
|
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[]} = {}; |
|
|
|
|
|
|
|
|
tasks.forEach(task => { |
|
|
groupedSubmissions[task._id] = []; |
|
|
}); |
|
|
|
|
|
|
|
|
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)); |
|
|
|
|
|
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); |
|
|
}); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
let translationBrief = null; |
|
|
if (tasks.length > 0) { |
|
|
translationBrief = tasks[0].translationBrief; |
|
|
console.log('Translation brief from task:', translationBrief); |
|
|
} else { |
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
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); |
|
|
}; |
|
|
}, []); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
setUserSubmissions(prev => { |
|
|
const current = prev[taskId] || []; |
|
|
|
|
|
const next = [{ ...created, isOwner: true }, ...current]; |
|
|
return { ...prev, [taskId]: next }; |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
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) { |
|
|
|
|
|
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 { |
|
|
|
|
|
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(() => { |
|
|
|
|
|
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 }); |
|
|
}); |
|
|
|
|
|
const release = () => { |
|
|
|
|
|
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) { |
|
|
|
|
|
|
|
|
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 {} |
|
|
|
|
|
|
|
|
if (taskId && !isSafari) { |
|
|
setUserSubmissions(prev => { |
|
|
const list = prev[taskId] || []; |
|
|
const next = list.filter(s => s._id !== submissionId); |
|
|
return { ...prev, [taskId]: next }; |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
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(() => { |
|
|
|
|
|
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 { |
|
|
|
|
|
requestAnimationFrame(() => requestAnimationFrame(() => fetchUserSubmissions(tutorialTasks))); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
} else { |
|
|
|
|
|
} |
|
|
} catch (error) { |
|
|
console.error('[Trace] Delete:Exception', error); |
|
|
|
|
|
} |
|
|
|
|
|
requestAnimationFrame(() => requestAnimationFrame(() => { |
|
|
if (isSafari && taskId) { |
|
|
|
|
|
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') || '{}'); |
|
|
|
|
|
|
|
|
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') || '{}'); |
|
|
|
|
|
|
|
|
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') || '{}'); |
|
|
|
|
|
|
|
|
if (user.role !== 'admin') { |
|
|
return; |
|
|
} |
|
|
|
|
|
console.log('Saving brief for week:', selectedWeek); |
|
|
console.log('Brief content:', editForm.translationBrief); |
|
|
|
|
|
|
|
|
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'); |
|
|
|
|
|
const briefKey = `translationBrief_week_${selectedWeek}`; |
|
|
localStorage.setItem(briefKey, editForm.translationBrief); |
|
|
setTutorialWeek(prev => prev ? { ...prev, translationBrief: editForm.translationBrief } : prev); |
|
|
setEditingBrief(prev => ({ ...prev, [selectedWeek]: false })); |
|
|
|
|
|
fetchTutorialTasks(false); |
|
|
} else { |
|
|
console.error('Failed to save brief:', response.data); |
|
|
} |
|
|
} else { |
|
|
|
|
|
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) { |
|
|
|
|
|
console.warn('Failed to save revision state to localStorage:', e); |
|
|
} |
|
|
}; |
|
|
|
|
|
const removeRevision = () => { |
|
|
|
|
|
if (!hasRevisionTasks) { |
|
|
setRevisionAdded(false); |
|
|
try { |
|
|
localStorage.removeItem(`tutorial_revision_added_week_${selectedWeek}`); |
|
|
} catch (e) { |
|
|
|
|
|
console.warn('Failed to remove revision state from localStorage:', e); |
|
|
} |
|
|
} else { |
|
|
|
|
|
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') || '{}'); |
|
|
|
|
|
|
|
|
if (user.role !== 'admin') { |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
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, |
|
|
|
|
|
...(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') || '{}'); |
|
|
|
|
|
|
|
|
if (user.role !== 'admin') { |
|
|
return; |
|
|
} |
|
|
|
|
|
if (!imageForm.imageUrl.trim()) { |
|
|
return; |
|
|
} |
|
|
|
|
|
const payload = { |
|
|
title: `Week ${selectedWeek} Image Task`, |
|
|
content: '', |
|
|
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') || '{}'); |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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> |
|
|
) : ( |
|
|
|
|
|
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> |
|
|
) |
|
|
)} |
|
|
|
|
|
{} |
|
|
<div className="space-y-6"> |
|
|
{} |
|
|
{(() => { 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> |
|
|
|
|
|
{} |
|
|
<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> |
|
|
|
|
|
{} |
|
|
{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> |
|
|
)} |
|
|
|
|
|
{} |
|
|
{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" |
|
|
/> |
|
|
)} |
|
|
|
|
|
{} |
|
|
{!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> |
|
|
|
|
|
{} |
|
|
{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); |
|
|
|
|
|
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; |