Spaces:
Sleeping
Sleeping
Site Maintainer
fix(Group Doc): prevent Actions shift; persist Copied label; fixed-width label
1eb53b7
| import React, { useState, useEffect, useCallback } from 'react'; | |
| import { useNavigate } from 'react-router-dom'; | |
| import { api } from '../services/api'; | |
| import { | |
| AcademicCapIcon, | |
| DocumentTextIcon, | |
| CheckCircleIcon, | |
| ClockIcon, | |
| ArrowRightIcon, | |
| PencilIcon, | |
| XMarkIcon, | |
| CheckIcon, | |
| PlusIcon, | |
| TrashIcon, | |
| ArrowTopRightOnSquareIcon, | |
| ArrowsRightLeftIcon | |
| } from '@heroicons/react/24/outline'; | |
| import ReactDOM from 'react-dom'; | |
| interface TutorialTask { | |
| _id: string; | |
| content: string; | |
| weekNumber: number; | |
| translationBrief?: string; | |
| imageUrl?: string; | |
| imageAlt?: string; | |
| imageSize?: number; | |
| imageAlignment?: 'left' | 'center' | 'right' | 'portrait-split'; | |
| position?: number; | |
| } | |
| interface TutorialWeek { | |
| weekNumber: number; | |
| translationBrief?: string; | |
| tasks: TutorialTask[]; | |
| } | |
| interface UserSubmission { | |
| _id: string; | |
| transcreation: string; | |
| status: string; | |
| score: number; | |
| groupNumber?: number; | |
| isOwner?: boolean; | |
| userId?: { | |
| _id: string; | |
| username: string; | |
| }; | |
| voteCounts: { | |
| '1': number; | |
| '2': number; | |
| '3': number; | |
| }; | |
| } | |
| const TutorialTasks: React.FC = () => { | |
| const [selectedWeek, setSelectedWeek] = useState<number>(() => { | |
| const savedWeek = localStorage.getItem('selectedTutorialWeek'); | |
| return savedWeek ? parseInt(savedWeek) : 1; | |
| }); | |
| const [isWeekTransitioning, setIsWeekTransitioning] = useState(false); | |
| const [tutorialTasks, setTutorialTasks] = useState<TutorialTask[]>([]); | |
| const [tutorialWeek, setTutorialWeek] = useState<TutorialWeek | null>(null); | |
| const [userSubmissions, setUserSubmissions] = useState<{[key: string]: UserSubmission[]}>({}); | |
| const [sourceHeights, setSourceHeights] = useState<{[key: string]: number}>({}); | |
| // Move a task up or down by normalizing positions for the current visible list (weeks 4–6 only) | |
| const moveTask = async (taskId: string, direction: 'up' | 'down') => { | |
| try { | |
| const isAdmin = JSON.parse(localStorage.getItem('user') || '{}').role === 'admin'; | |
| if (!isAdmin || selectedWeek < 4) return; | |
| // Build ordered list for the current week from what is rendered | |
| const current = tutorialTasks.filter(t => t.weekNumber === selectedWeek); | |
| const index = current.findIndex(t => t._id === taskId); | |
| if (index === -1) return; | |
| const targetIndex = direction === 'up' ? index - 1 : index + 1; | |
| if (targetIndex < 0 || targetIndex >= current.length) return; | |
| // Normalize positions to 0..n-1 based on current screen order | |
| const normalized = current.map((t, i) => ({ id: t._id, position: i })); | |
| // Swap the two entries (calculate new positions first) | |
| const posA = normalized[index].position; | |
| const posB = normalized[targetIndex].position; | |
| normalized[index].position = posB; | |
| normalized[targetIndex].position = posA; | |
| // Optimistic UI update: swap in local state immediately for smoother UX | |
| const prevState = tutorialTasks; | |
| setTutorialTasks((prev) => { | |
| const next = [...prev]; | |
| // find actual indices in full list and swap their relative order by updating their position fields | |
| const aId = normalized[index].id; | |
| const bId = normalized[targetIndex].id; | |
| return next.map(item => { | |
| if (item._id === aId) return { ...item, position: posB } as any; | |
| if (item._id === bId) return { ...item, position: posA } as any; | |
| return item; | |
| }); | |
| }); | |
| // Send both updates in parallel; if either fails, revert then refetch | |
| await Promise.all([ | |
| api.put(`/api/auth/admin/tutorial-tasks/${normalized[index].id}/position`, { position: posB }), | |
| api.put(`/api/auth/admin/tutorial-tasks/${normalized[targetIndex].id}/position`, { position: posA }) | |
| ]); | |
| // Light refresh to ensure list order is consistent with server | |
| fetchTutorialTasks(false); | |
| } catch (error) { | |
| console.error('Reorder failed', error); | |
| } | |
| }; | |
| const [loading, setLoading] = useState(true); | |
| const [submitting, setSubmitting] = useState<{[key: string]: boolean}>({}); | |
| const [translationText, setTranslationText] = useState<{[key: string]: string}>({}); | |
| const [selectedGroups, setSelectedGroups] = useState<{[key: string]: number}>({}); | |
| const [expandedSections, setExpandedSections] = useState<{[key: string]: boolean}>({}); | |
| const GroupDocSection: React.FC<{ weekNumber: number }> = ({ weekNumber }) => { | |
| 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 isAdmin = (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin'); | |
| 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 { | |
| const resp = await api.get(`/api/docs/list?weekNumber=${weekNumber}`); | |
| setDocs(resp.data?.docs || []); | |
| } catch (e) { | |
| setDocs([]); | |
| } | |
| }, [weekNumber]); | |
| useEffect(() => { loadDocs(); }, [loadDocs]); | |
| const current = docs.find(d => d.groupNumber === group); | |
| const createDoc = async () => { | |
| try { | |
| setCreating(true); | |
| setErrorMsg(''); | |
| const url = urlInput.trim(); | |
| if (!url) { | |
| setErrorMsg('Please paste a Google Doc link.'); | |
| return; | |
| } | |
| const isValid = /docs\.google\.com\/document\/d\//.test(url); | |
| if (!isValid) { | |
| setErrorMsg('Provide a valid Google Doc link (docs.google.com/document/d/...).'); | |
| return; | |
| } | |
| await api.post('/api/docs/create', { weekNumber, groupNumber: group, docUrl: url }); | |
| await loadDocs(); | |
| setUrlInput(''); | |
| } finally { | |
| setCreating(false); | |
| } | |
| }; | |
| const copyLink = async (link: string) => { | |
| try { | |
| await navigator.clipboard.writeText(link); | |
| // Persist until refresh: do not clear after timeout | |
| setCopiedLink(link); | |
| } catch {} | |
| }; | |
| return ( | |
| <div> | |
| {/* 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="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm">{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-indigo-700"> | |
| <ArrowTopRightOnSquareIcon className="h-4 w-4" /> Open | |
| </a> | |
| <button onClick={() => copyLink(d.docUrl)} className="inline-flex items-center gap-1 text-indigo-700"> | |
| <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); }} className="inline-flex items-center gap-1 text-gray-700"> | |
| <ArrowsRightLeftIcon className="h-4 w-4" /> Edit | |
| </button> | |
| )} | |
| </div> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| // Basic inline formatting helpers (bold/italic via simple markdown) for weeks 4–6 | |
| const renderFormatted = (text: string) => { | |
| const escape = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); | |
| // Auto-linker: supports [label](url), plain URLs, and www.* without touching existing href attributes | |
| const html = escape(text) | |
| // Markdown-style links: [label](https://example.com) | |
| .replace(/\[([^\]]+?)\]\((https?:\/\/[^\s)]+)\)/g, '<a class="text-indigo-600 underline" href="$2" target="_blank" rel="noopener noreferrer">$1</a>') | |
| // Plain URLs with protocol, avoid matching inside attributes (require a non-attribute preceding char) | |
| .replace(/(^|[^=\"'\/:])(https?:\/\/[^\s<]+)/g, (m, p1, url) => `${p1}<a class="text-indigo-600 underline" href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`) | |
| // www.* domains (prepend https://), also avoid attributes | |
| .replace(/(^|[^=\"'\/:])(www\.[^\s<]+)/g, (m, p1, host) => `${p1}<a class="text-indigo-600 underline" href="https://${host}" target="_blank" rel="noopener noreferrer">${host}</a>`) | |
| .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') | |
| .replace(/\*(.+?)\*/g, '<em>$1</em>') | |
| .replace(/\n/g, '<br/>'); | |
| return <span dangerouslySetInnerHTML={{ __html: html }} />; | |
| }; | |
| const applyLinkFormat = ( | |
| elementId: string, | |
| current: string, | |
| setValue: (v: string) => void | |
| ) => { | |
| const urlInput = window.prompt('Enter URL (e.g., https://example.com):'); | |
| if (!urlInput) return; | |
| // Sanitize URL: ensure protocol, and strip accidental trailing quotes/attributes pasted from elsewhere | |
| let url = /^https?:\/\//i.test(urlInput) ? urlInput : `https://${urlInput}`; | |
| url = url.replace(/["'>)\s]+$/g, ''); | |
| const el = document.getElementById(elementId) as HTMLTextAreaElement | null; | |
| if (!el) { | |
| setValue(`${current}[link](${url})`); | |
| return; | |
| } | |
| const start = el.selectionStart ?? current.length; | |
| const end = el.selectionEnd ?? current.length; | |
| const before = current.slice(0, start); | |
| const selection = current.slice(start, end) || 'link'; | |
| const after = current.slice(end); | |
| setValue(`${before}[${selection}](${url})${after}`); | |
| // Restore focus and selection | |
| setTimeout(() => { | |
| el.focus(); | |
| const newPos = before.length + selection.length + 4 + url.length + 2; // rough caret placement | |
| try { el.setSelectionRange(newPos, newPos); } catch {} | |
| }, 0); | |
| }; | |
| const applyInlineFormat = ( | |
| elementId: string, | |
| current: string, | |
| setValue: (v: string) => void, | |
| wrapper: '**' | '*' | |
| ) => { | |
| const el = document.getElementById(elementId) as HTMLTextAreaElement | null; | |
| if (!el) { | |
| setValue(current + wrapper + wrapper); | |
| return; | |
| } | |
| const start = el.selectionStart ?? current.length; | |
| const end = el.selectionEnd ?? current.length; | |
| const before = current.slice(0, start); | |
| const selection = current.slice(start, end); | |
| const after = current.slice(end); | |
| const next = `${before}${wrapper}${selection}${wrapper}${after}`; | |
| setValue(next); | |
| setTimeout(() => { | |
| try { | |
| el.focus(); | |
| el.selectionStart = start + wrapper.length; | |
| el.selectionEnd = end + wrapper.length; | |
| } catch {} | |
| }, 0); | |
| }; | |
| const [editingTask, setEditingTask] = useState<string | null>(null); | |
| const [editingBrief, setEditingBrief] = useState<{[key: number]: boolean}>({}); | |
| const [addingTask, setAddingTask] = useState<boolean>(false); | |
| const [addingImage, setAddingImage] = useState<boolean>(false); | |
| const [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, 6]; | |
| const handleWeekChange = async (week: number) => { | |
| setIsWeekTransitioning(true); | |
| // Clear existing data first | |
| setTutorialTasks([]); | |
| setTutorialWeek(null); | |
| setUserSubmissions({}); | |
| // Update state and localStorage | |
| setSelectedWeek(week); | |
| localStorage.setItem('selectedTutorialWeek', week.toString()); | |
| // Force a small delay to ensure state is updated | |
| await new Promise(resolve => setTimeout(resolve, 50)); | |
| // Wait for actual content to load before ending animation | |
| try { | |
| // Fetch new week's data with the updated selectedWeek | |
| const response = await api.get(`/api/search/tutorial-tasks/${week}`); | |
| if (response.data) { | |
| const tasks = response.data; | |
| console.log('Fetched tasks for week', week, ':', tasks); | |
| // Ensure tasks are sorted by title | |
| const sortedTasks = tasks.sort((a: any, b: any) => { | |
| const aNum = parseInt(a.title?.match(/ST (\d+)/)?.[1] || '0'); | |
| const bNum = parseInt(b.title?.match(/ST (\d+)/)?.[1] || '0'); | |
| return aNum - bNum; | |
| }); | |
| setTutorialTasks(sortedTasks); | |
| // Use translation brief from tasks or localStorage | |
| let translationBrief = null; | |
| if (tasks.length > 0) { | |
| translationBrief = tasks[0].translationBrief; | |
| } else { | |
| const briefKey = `translationBrief_week_${week}`; | |
| translationBrief = localStorage.getItem(briefKey); | |
| } | |
| const tutorialWeekData: TutorialWeek = { | |
| weekNumber: week, | |
| translationBrief: translationBrief, | |
| tasks: tasks | |
| }; | |
| setTutorialWeek(tutorialWeekData); | |
| // Fetch user submissions for the new tasks | |
| await fetchUserSubmissions(tasks); | |
| } | |
| // Wait longer for DOM to update with new content (especially for Week 2) | |
| const delay = week === 2 ? 400 : 200; | |
| await new Promise(resolve => setTimeout(resolve, delay)); | |
| } catch (error) { | |
| console.error('Error loading week data:', error); | |
| } finally { | |
| // End transition after content is loaded and rendered | |
| setIsWeekTransitioning(false); | |
| } | |
| }; | |
| const handleFileUpload = async (file: File): Promise<string> => { | |
| try { | |
| setUploading(true); | |
| // Convert file to data URL for display | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => { | |
| const dataUrl = reader.result as string; | |
| console.log('File uploaded:', file.name, 'Size:', file.size); | |
| console.log('Generated data URL:', dataUrl.substring(0, 50) + '...'); | |
| resolve(dataUrl); | |
| }; | |
| reader.onerror = () => { | |
| console.error('Error reading file:', reader.error); | |
| reject(reader.error); | |
| }; | |
| reader.readAsDataURL(file); | |
| }); | |
| } catch (error) { | |
| console.error('Error uploading file:', error); | |
| throw error; | |
| } finally { | |
| setUploading(false); | |
| } | |
| }; | |
| const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = event.target.files?.[0]; | |
| if (file) { | |
| setSelectedFile(file); | |
| } | |
| }; | |
| const toggleExpanded = (taskId: string) => { | |
| setExpandedSections(prev => ({ | |
| ...prev, | |
| [taskId]: !prev[taskId] | |
| })); | |
| }; | |
| const fetchUserSubmissions = useCallback(async (tasks: TutorialTask[]) => { | |
| try { | |
| const token = localStorage.getItem('token'); | |
| const user = localStorage.getItem('user'); | |
| if (!token || !user) { | |
| return; | |
| } | |
| const response = await api.get('/api/submissions/my-submissions'); | |
| if (response.data && response.data.submissions) { | |
| const data = response.data; | |
| const groupedSubmissions: {[key: string]: UserSubmission[]} = {}; | |
| // Initialize all tasks with empty arrays | |
| tasks.forEach(task => { | |
| groupedSubmissions[task._id] = []; | |
| }); | |
| // Then populate with actual submissions and mark ownership for edit visibility after refresh/login | |
| if (data.submissions && Array.isArray(data.submissions)) { | |
| data.submissions.forEach((submission: any) => { | |
| const sourceTextId = submission.sourceTextId?._id || submission.sourceTextId; | |
| if (sourceTextId && groupedSubmissions[sourceTextId]) { | |
| groupedSubmissions[sourceTextId].push({ ...submission, isOwner: true }); | |
| } | |
| }); | |
| } | |
| setUserSubmissions(groupedSubmissions); | |
| } | |
| } catch (error) { | |
| console.error('Error fetching user submissions:', error); | |
| } | |
| }, []); | |
| const fetchTutorialTasks = useCallback(async (showLoading = true) => { | |
| try { | |
| if (showLoading) { | |
| setLoading(true); | |
| } | |
| const token = localStorage.getItem('token'); | |
| const user = localStorage.getItem('user'); | |
| if (!token || !user) { | |
| navigate('/login'); | |
| return; | |
| } | |
| const response = await api.get(`/api/search/tutorial-tasks/${selectedWeek}`); | |
| if (response.data) { | |
| const tasks = response.data; | |
| console.log('Fetched tasks:', tasks); | |
| console.log('Tasks with images:', tasks.filter((task: TutorialTask) => task.imageUrl)); | |
| // Debug: Log each task's fields | |
| tasks.forEach((task: any, index: number) => { | |
| console.log(`Task ${index} fields:`, { | |
| _id: task._id, | |
| content: task.content, | |
| imageUrl: task.imageUrl, | |
| imageAlt: task.imageAlt, | |
| translationBrief: task.translationBrief, | |
| weekNumber: task.weekNumber, | |
| category: task.category | |
| }); | |
| console.log(`Task ${index} imageUrl:`, task.imageUrl); | |
| console.log(`Task ${index} translationBrief:`, task.translationBrief); | |
| }); | |
| // Ensure tasks are sorted by title to maintain correct order (Tutorial ST 1, Tutorial ST 2, etc.) | |
| const sortedTasks = tasks.sort((a: any, b: any) => { | |
| const aNum = parseInt(a.title?.match(/ST (\d+)/)?.[1] || '0'); | |
| const bNum = parseInt(b.title?.match(/ST (\d+)/)?.[1] || '0'); | |
| return aNum - bNum; | |
| }); | |
| setTutorialTasks(sortedTasks); | |
| // Use translation brief from tasks or localStorage | |
| let translationBrief = null; | |
| if (tasks.length > 0) { | |
| translationBrief = tasks[0].translationBrief; | |
| console.log('Translation brief from task:', translationBrief); | |
| } else { | |
| // Check localStorage for brief if no tasks exist | |
| const briefKey = `translationBrief_week_${selectedWeek}`; | |
| translationBrief = localStorage.getItem(briefKey); | |
| console.log('Translation brief from localStorage:', translationBrief); | |
| console.log('localStorage key:', briefKey); | |
| } | |
| console.log('Final translation brief:', translationBrief); | |
| const tutorialWeekData: TutorialWeek = { | |
| weekNumber: selectedWeek, | |
| translationBrief: translationBrief, | |
| tasks: tasks | |
| }; | |
| setTutorialWeek(tutorialWeekData); | |
| await fetchUserSubmissions(tasks); | |
| } else { | |
| console.error('Failed to fetch tutorial tasks'); | |
| } | |
| } catch (error) { | |
| console.error('Error fetching tutorial tasks:', error); | |
| } finally { | |
| if (showLoading) { | |
| setLoading(false); | |
| } | |
| } | |
| }, [selectedWeek, fetchUserSubmissions, navigate]); | |
| useEffect(() => { | |
| const user = localStorage.getItem('user'); | |
| if (!user) { | |
| navigate('/login'); | |
| return; | |
| } | |
| fetchTutorialTasks(); | |
| }, [fetchTutorialTasks, navigate]); | |
| // Listen for week reset events from page navigation | |
| useEffect(() => { | |
| const handleWeekReset = (event: CustomEvent) => { | |
| if (event.detail.page === 'tutorial-tasks') { | |
| console.log('Week reset event received for tutorial tasks'); | |
| setSelectedWeek(event.detail.week); | |
| localStorage.setItem('selectedTutorialWeek', event.detail.week.toString()); | |
| } | |
| }; | |
| window.addEventListener('weekReset', handleWeekReset as EventListener); | |
| return () => { | |
| window.removeEventListener('weekReset', handleWeekReset as EventListener); | |
| }; | |
| }, []); | |
| // Refresh submissions when user changes (after login/logout) | |
| useEffect(() => { | |
| const user = localStorage.getItem('user'); | |
| if (user && tutorialTasks.length > 0) { | |
| fetchUserSubmissions(tutorialTasks); | |
| } | |
| }, [tutorialTasks, fetchUserSubmissions]); | |
| const handleSubmitTranslation = async (taskId: string) => { | |
| if (!translationText[taskId]?.trim()) { | |
| return; | |
| } | |
| if (!selectedGroups[taskId]) { | |
| return; | |
| } | |
| try { | |
| setSubmitting({ ...submitting, [taskId]: true }); | |
| const user = JSON.parse(localStorage.getItem('user') || '{}'); | |
| const response = await api.post('/api/submissions', { | |
| sourceTextId: taskId, | |
| transcreation: translationText[taskId], | |
| groupNumber: selectedGroups[taskId], | |
| culturalAdaptations: [], | |
| username: user.name || 'Unknown' | |
| }); | |
| if (response.status >= 200 && response.status < 300) { | |
| const result = response.data; | |
| console.log('Submission created successfully:', result); | |
| setTranslationText({ ...translationText, [taskId]: '' }); | |
| setSelectedGroups({ ...selectedGroups, [taskId]: 0 }); | |
| await fetchUserSubmissions(tutorialTasks); | |
| } else { | |
| console.error('Failed to submit translation:', response.data); | |
| } | |
| } catch (error) { | |
| console.error('Error submitting translation:', error); | |
| } finally { | |
| setSubmitting({ ...submitting, [taskId]: false }); | |
| } | |
| }; | |
| 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) { | |
| setEditingSubmission(null); | |
| setEditSubmissionText(''); | |
| await 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) => { | |
| try { | |
| const response = await api.delete(`/api/submissions/${submissionId}`); | |
| if (response.status === 200) { | |
| await fetchUserSubmissions(tutorialTasks); | |
| } else { | |
| } | |
| } catch (error) { | |
| console.error('Error deleting submission:', error); | |
| } | |
| }; | |
| const getStatusIcon = (status: string) => { | |
| switch (status) { | |
| case 'approved': | |
| return <CheckCircleIcon className="h-5 w-5 text-green-500" />; | |
| case 'pending': | |
| return <ClockIcon className="h-5 w-5 text-yellow-500" />; | |
| default: | |
| return <ClockIcon className="h-5 w-5 text-gray-500" />; | |
| } | |
| }; | |
| const startEditing = (task: TutorialTask) => { | |
| setEditingTask(task._id); | |
| setEditForm({ | |
| content: task.content, | |
| translationBrief: task.translationBrief || '', | |
| imageUrl: task.imageUrl || '', | |
| imageAlt: task.imageAlt || '' | |
| }); | |
| }; | |
| const startEditingBrief = () => { | |
| setEditingBrief(prev => ({ ...prev, [selectedWeek]: true })); | |
| setEditForm({ | |
| content: '', | |
| translationBrief: tutorialWeek?.translationBrief || '', | |
| imageUrl: '', | |
| imageAlt: '' | |
| }); | |
| }; | |
| const startAddingBrief = () => { | |
| setEditingBrief(prev => ({ ...prev, [selectedWeek]: true })); | |
| setEditForm({ | |
| content: '', | |
| translationBrief: '', | |
| imageUrl: '', | |
| imageAlt: '' | |
| }); | |
| }; | |
| const removeBrief = async () => { | |
| try { | |
| setSaving(true); | |
| const token = localStorage.getItem('token'); | |
| const user = JSON.parse(localStorage.getItem('user') || '{}'); | |
| // Check if user is admin | |
| if (user.role !== 'admin') { | |
| return; | |
| } | |
| const response = await api.put(`/api/auth/admin/tutorial-brief/${selectedWeek}`, { | |
| translationBrief: '', | |
| weekNumber: selectedWeek | |
| }); | |
| if (response.status >= 200 && response.status < 300) { | |
| const briefKey = `translationBrief_week_${selectedWeek}`; | |
| localStorage.removeItem(briefKey); | |
| setEditForm((prev) => ({ ...prev, translationBrief: '' })); | |
| await fetchTutorialTasks(); | |
| } else { | |
| console.error('Failed to remove translation brief:', response.data); | |
| } | |
| } catch (error) { | |
| console.error('Failed to remove translation brief:', error); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const cancelEditing = () => { | |
| setEditingTask(null); | |
| setEditingBrief(prev => ({ ...prev, [selectedWeek]: false })); | |
| setEditForm({ | |
| content: '', | |
| translationBrief: '', | |
| imageUrl: '', | |
| imageAlt: '' | |
| }); | |
| setSelectedFile(null); | |
| }; | |
| const saveTask = async () => { | |
| if (!editingTask) return; | |
| try { | |
| setSaving(true); | |
| const token = localStorage.getItem('token'); | |
| const user = JSON.parse(localStorage.getItem('user') || '{}'); | |
| // Check if user is admin | |
| if (user.role !== 'admin') { | |
| return; | |
| } | |
| const updateData = { | |
| ...editForm, | |
| weekNumber: selectedWeek | |
| }; | |
| console.log('Saving task with data:', updateData); | |
| const response = await api.put(`/api/auth/admin/tutorial-tasks/${editingTask}`, updateData); | |
| if (response.status >= 200 && response.status < 300) { | |
| await fetchTutorialTasks(false); | |
| setEditingTask(null); | |
| } else { | |
| console.error('Failed to update tutorial task:', response.data); | |
| } | |
| } catch (error) { | |
| console.error('Failed to update tutorial task:', error); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const saveBrief = async () => { | |
| try { | |
| setSaving(true); | |
| const user = JSON.parse(localStorage.getItem('user') || '{}'); | |
| // Check if user is admin | |
| if (user.role !== 'admin') { | |
| return; | |
| } | |
| console.log('Saving brief for week:', selectedWeek); | |
| console.log('Brief content:', editForm.translationBrief); | |
| // Save brief by creating or updating the first task of the week | |
| if (tutorialTasks.length > 0) { | |
| const firstTask = tutorialTasks[0]; | |
| console.log('Updating first task with brief:', firstTask._id); | |
| const response = await api.put(`/api/auth/admin/tutorial-tasks/${firstTask._id}`, { | |
| ...firstTask, | |
| translationBrief: editForm.translationBrief, | |
| weekNumber: selectedWeek | |
| }); | |
| if (response.status >= 200 && response.status < 300) { | |
| console.log('Brief saved successfully'); | |
| // Optimistic UI update | |
| const briefKey = `translationBrief_week_${selectedWeek}`; | |
| localStorage.setItem(briefKey, editForm.translationBrief); | |
| setTutorialWeek(prev => prev ? { ...prev, translationBrief: editForm.translationBrief } : prev); | |
| setEditingBrief(prev => ({ ...prev, [selectedWeek]: false })); | |
| // Background refresh | |
| fetchTutorialTasks(false); | |
| } else { | |
| console.error('Failed to save brief:', response.data); | |
| } | |
| } else { | |
| // If no tasks exist, save the brief to localStorage | |
| console.log('No tasks available to save brief to - saving to localStorage'); | |
| const briefKey = `translationBrief_week_${selectedWeek}`; | |
| localStorage.setItem(briefKey, editForm.translationBrief); | |
| setEditingBrief(prev => ({ ...prev, [selectedWeek]: false })); | |
| } | |
| } catch (error) { | |
| console.error('Failed to update translation brief:', error); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const startAddingTask = () => { | |
| setAddingTask(true); | |
| setEditForm({ | |
| content: '', | |
| translationBrief: '', | |
| imageUrl: '', | |
| imageAlt: '' | |
| }); | |
| }; | |
| const cancelAddingTask = () => { | |
| setAddingTask(false); | |
| setEditForm({ | |
| content: '', | |
| translationBrief: '', | |
| imageUrl: '', | |
| imageAlt: '' | |
| }); | |
| setSelectedFile(null); | |
| }; | |
| const startAddingImage = () => { | |
| setAddingImage(true); | |
| setImageForm({ | |
| imageUrl: '', | |
| imageAlt: '', | |
| imageSize: 200, | |
| imageAlignment: 'center' | |
| }); | |
| }; | |
| const cancelAddingImage = () => { | |
| setAddingImage(false); | |
| setImageForm({ | |
| imageUrl: '', | |
| imageAlt: '', | |
| imageSize: 200, | |
| imageAlignment: 'center' | |
| }); | |
| }; | |
| const saveNewTask = async () => { | |
| try { | |
| setSaving(true); | |
| const user = JSON.parse(localStorage.getItem('user') || '{}'); | |
| // Check if user is admin | |
| if (user.role !== 'admin') { | |
| return; | |
| } | |
| // Allow either content or imageUrl, but not both empty | |
| if (!editForm.content.trim() && !editForm.imageUrl.trim()) { | |
| return; | |
| } | |
| console.log('Saving new task for week:', selectedWeek); | |
| console.log('Task content:', editForm.content); | |
| console.log('Image URL:', editForm.imageUrl); | |
| console.log('Image Alt:', editForm.imageAlt); | |
| const taskData = { | |
| title: `Week ${selectedWeek} Tutorial Task`, | |
| content: editForm.content, | |
| sourceLanguage: 'English', | |
| weekNumber: selectedWeek, | |
| category: 'tutorial', | |
| imageUrl: editForm.imageUrl || null, | |
| imageAlt: editForm.imageAlt || null, | |
| // Add imageSize and imageAlignment for image-only content | |
| ...(editForm.imageUrl && !editForm.content.trim() && { imageSize: 200 }), | |
| ...(editForm.imageUrl && !editForm.content.trim() && { imageAlignment: 'center' }) | |
| }; | |
| console.log('Task data being sent:', JSON.stringify(taskData, null, 2)); | |
| console.log('Sending task data:', taskData); | |
| const response = await api.post('/api/auth/admin/tutorial-tasks', taskData); | |
| console.log('Task save response:', response.data); | |
| if (response.status >= 200 && response.status < 300) { | |
| console.log('Task saved successfully'); | |
| console.log('Saved task response:', response.data); | |
| console.log('Saved task response keys:', Object.keys(response.data || {})); | |
| console.log('Saved task response.task:', response.data?.task); | |
| console.log('Saved task response.task.imageUrl:', response.data?.task?.imageUrl); | |
| console.log('Saved task response.task.translationBrief:', response.data?.task?.translationBrief); | |
| await fetchTutorialTasks(false); | |
| setAddingTask(false); | |
| } else { | |
| console.error('Failed to add tutorial task:', response.data); | |
| } | |
| } catch (error) { | |
| console.error('Failed to add tutorial task:', error); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const saveNewImage = async () => { | |
| try { | |
| setSaving(true); | |
| const user = JSON.parse(localStorage.getItem('user') || '{}'); | |
| // Check if user is admin | |
| if (user.role !== 'admin') { | |
| return; | |
| } | |
| if (!imageForm.imageUrl.trim()) { | |
| return; | |
| } | |
| const payload = { | |
| title: `Week ${selectedWeek} Image Task`, | |
| content: '', // Empty content for image-only task | |
| sourceLanguage: 'English', | |
| weekNumber: selectedWeek, | |
| category: 'tutorial', | |
| imageUrl: imageForm.imageUrl.trim(), | |
| imageAlt: imageForm.imageAlt.trim() || null, | |
| imageSize: imageForm.imageSize, | |
| imageAlignment: imageForm.imageAlignment | |
| }; | |
| console.log('Saving new image task with payload:', payload); | |
| const response = await api.post('/api/auth/admin/tutorial-tasks', payload); | |
| if (response.data) { | |
| console.log('Image task saved successfully:', response.data); | |
| await fetchTutorialTasks(false); | |
| setAddingImage(false); | |
| } else { | |
| console.error('Failed to save image task'); | |
| } | |
| } catch (error) { | |
| console.error('Failed to add image task:', error); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const deleteTask = async (taskId: string) => { | |
| try { | |
| const token = localStorage.getItem('token'); | |
| const user = JSON.parse(localStorage.getItem('user') || '{}'); | |
| // Check if user is admin | |
| if (user.role !== 'admin') { | |
| return; | |
| } | |
| const response = await api.delete(`/api/auth/admin/tutorial-tasks/${taskId}`); | |
| if (response.status >= 200 && response.status < 300) { | |
| await fetchTutorialTasks(false); | |
| } else { | |
| console.error('Failed to delete tutorial task:', response.data); | |
| } | |
| } catch (error) { | |
| console.error('Failed to delete tutorial task:', error); | |
| } | |
| }; | |
| // Remove intrusive loading screen - just show content with loading state | |
| return ( | |
| <div className="min-h-screen bg-gray-50 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"> | |
| <AcademicCapIcon className="h-8 w-8 text-indigo-900 mr-3" /> | |
| <h1 className="text-3xl font-bold text-gray-900">Tutorial Tasks</h1> | |
| </div> | |
| <p className="text-gray-600"> | |
| 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-2 overflow-x-auto pb-2"> | |
| {weeks.map((week) => ( | |
| <button | |
| key={week} | |
| onClick={() => handleWeekChange(week)} | |
| className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-all duration-200 ease-in-out ${ | |
| selectedWeek === week | |
| ? 'bg-indigo-600 text-white' | |
| : 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300' | |
| }`} | |
| > | |
| Week {week} | |
| </button> | |
| ))} | |
| </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-indigo-600"></div> | |
| <span className="text-gray-700 font-medium">Loading...</span> | |
| </div> | |
| </div> | |
| )} | |
| {!isWeekTransitioning && ( | |
| <> | |
| {/* Translation Brief - Shown once at the top */} | |
| {tutorialWeek && tutorialWeek.translationBrief ? ( | |
| <div className="bg-white rounded-lg p-6 mb-8 border border-gray-200 shadow-sm"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <div className="flex items-center space-x-3"> | |
| <div className="bg-indigo-600 rounded-lg p-2"> | |
| <DocumentTextIcon className="h-5 w-5 text-white" /> | |
| </div> | |
| <h3 className="text-indigo-900 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="bg-indigo-500 hover:bg-indigo-600 text-white px-3 py-1 rounded-md transition-colors duration-200 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" /> | |
| )} | |
| </button> | |
| <button | |
| onClick={cancelEditing} | |
| className="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded-md transition-colors duration-200 text-sm" | |
| > | |
| <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-indigo-100 text-indigo-900 rounded">B</button> | |
| <button onClick={() => applyInlineFormat('tutorial-brief-input', editForm.translationBrief, v => setEditForm({ ...editForm, translationBrief: v }), '*')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 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-indigo-100 text-indigo-900 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-indigo-500 focus:border-indigo-500" | |
| rows={6} | |
| placeholder="Enter translation brief..." | |
| /> | |
| </div> | |
| ) : ( | |
| <div className="text-gray-900 leading-relaxed text-lg font-smiley whitespace-pre-wrap">{renderFormatted(tutorialWeek.translationBrief || '')}</div> | |
| )} | |
| {/* Group Google Doc (refined) */} | |
| <div className="mt-6 bg-white rounded-xl border border-gray-200 shadow-sm"> | |
| <div className="px-6 pt-6"> | |
| <h4 className="text-2xl font-bold text-gray-900">Group Google Doc</h4> | |
| <p className="mt-2 text-gray-600">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-indigo-50 rounded-lg"> | |
| <p className="text-indigo-900 text-sm"> | |
| <strong>Note:</strong> Tutorial tasks are completed as group submissions. Each group should collaborate to create a single translation per task. | |
| </p> | |
| </div> | |
| </div> | |
| ) : ( | |
| // Show add brief button when no brief exists | |
| JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && ( | |
| <div className="bg-white rounded-lg p-6 mb-8 border border-gray-200 border-dashed shadow-sm"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <div className="flex items-center space-x-3"> | |
| <div className="bg-indigo-100 rounded-lg p-2"> | |
| <DocumentTextIcon className="h-5 w-5 text-indigo-900" /> | |
| </div> | |
| <h3 className="text-indigo-900 font-semibold text-xl">Translation Brief</h3> | |
| </div> | |
| <button | |
| onClick={startAddingBrief} | |
| className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm" | |
| > | |
| <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-indigo-100 text-indigo-900 rounded">B</button> | |
| <button onClick={() => applyInlineFormat('tutorial-brief-input', editForm.translationBrief, v => setEditForm({ ...editForm, translationBrief: v }), '*')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 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-indigo-100 text-indigo-900 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-indigo-500 focus:border-indigo-500" | |
| rows={6} | |
| placeholder="Enter translation brief..." | |
| /> | |
| <div className="flex justify-end space-x-2"> | |
| <button | |
| onClick={saveBrief} | |
| disabled={saving} | |
| className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm" | |
| > | |
| {saving ? ( | |
| <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div> | |
| ) : ( | |
| <> | |
| <CheckIcon className="h-5 w-5" /> | |
| <span className="font-medium">Save Brief</span> | |
| </> | |
| )} | |
| </button> | |
| <button | |
| onClick={cancelEditing} | |
| className="bg-gray-500 hover:bg-gray-600 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm" | |
| > | |
| <XMarkIcon className="h-5 w-5" /> | |
| <span className="font-medium">Cancel</span> | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| )} | |
| {/* Tutorial Tasks */} | |
| <div className="space-y-6"> | |
| {/* Add New Tutorial Task Section */} | |
| {JSON.parse(localStorage.getItem('user') || '{}').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-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-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 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-indigo-500 focus:border-indigo-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={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-indigo-500 focus:border-indigo-500" | |
| 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-indigo-500 focus:border-indigo-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); | |
| 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="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm" | |
| > | |
| <PlusIcon className="h-5 w-5" /> | |
| <span className="font-medium">Add Task</span> | |
| </button> | |
| {selectedWeek >= 3 && ( | |
| <button | |
| onClick={startAddingImage} | |
| className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm" | |
| > | |
| <PlusIcon className="h-5 w-5" /> | |
| <span className="font-medium">Add Image</span> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {tutorialTasks.length === 0 && !addingTask ? ( | |
| <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} className="bg-white rounded-xl shadow-lg border border-gray-100 p-8 hover:shadow-xl transition-shadow duration-300"> | |
| <div className="mb-6"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <div className="flex items-center space-x-3"> | |
| <div className="bg-indigo-100 rounded-full p-2"> | |
| <DocumentTextIcon className="h-5 w-5 text-indigo-900" /> | |
| </div> | |
| <div> | |
| <h3 className="text-lg font-semibold text-gray-900 flex items-center">Source Text #{tutorialTasks.indexOf(task) + 1} | |
| {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && selectedWeek >= 4 && ( | |
| <span className="ml-2 inline-flex items-center space-x-1"> | |
| <button | |
| className="px-2 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200" | |
| onClick={() => moveTask(task._id, 'up')} | |
| title="Move up" | |
| >↑</button> | |
| <button | |
| className="px-2 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200" | |
| onClick={() => moveTask(task._id, 'down')} | |
| title="Move down" | |
| >↓</button> | |
| </span> | |
| )} | |
| </h3> | |
| </div> | |
| </div> | |
| {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && ( | |
| <div className="flex items-center space-x-2"> | |
| {editingTask === task._id ? ( | |
| <> | |
| <button | |
| onClick={saveTask} | |
| disabled={saving} | |
| className="bg-green-100 hover:bg-green-200 text-green-700 px-3 py-1 rounded-lg transition-colors duration-200" | |
| > | |
| {saving ? ( | |
| <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600"></div> | |
| ) : ( | |
| <CheckIcon className="h-4 w-4" /> | |
| )} | |
| </button> | |
| <button | |
| onClick={cancelEditing} | |
| className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200" | |
| > | |
| <XMarkIcon className="h-4 w-4" /> | |
| </button> | |
| </> | |
| ) : ( | |
| <> | |
| <button | |
| onClick={() => startEditing(task)} | |
| className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm" | |
| > | |
| <PencilIcon className="h-4 w-4" /> | |
| </button> | |
| <button | |
| onClick={() => deleteTask(task._id)} | |
| className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200" | |
| > | |
| <TrashIcon className="h-4 w-4" /> | |
| </button> | |
| </> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| {/* Content - Clean styling with image support */} | |
| <div className="bg-gradient-to-r from-indigo-50 to-blue-50 rounded-xl p-6 mb-6 border border-indigo-200"> | |
| {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-indigo-500 focus:border-indigo-500" | |
| 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-indigo-500 focus:border-indigo-500" | |
| 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-indigo-500 focus:border-indigo-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); | |
| 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}`} | |
| ref={(el) => { | |
| if (el) { | |
| const h = el.getBoundingClientRect().height; | |
| if (h && Math.abs((sourceHeights[task._id] || 0) - h) > 1) { | |
| setSourceHeights((prev) => ({ ...prev, [task._id]: h })); | |
| } | |
| } | |
| }} | |
| className="text-indigo-900 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) => setSelectedGroups({ ...selectedGroups, [task._id]: parseInt(e.target.value) })} | |
| 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) => setTranslationText({ ...translationText, [task._id]: 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: sourceHeights[task._id] ? `${sourceHeights[task._id]}px` : 'auto' }} | |
| 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="bg-indigo-500 hover:bg-indigo-600 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 | |
| <div className="flex flex-col md:flex-row gap-6 items-start"> | |
| <div className="w-full md:w-1/2 flex justify-center"> | |
| {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> | |
| ) : ( | |
| <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> | |
| <div className="w-full md:w-1/2"> | |
| <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> | |
| {/* All Submissions for this Task */} | |
| {userSubmissions[task._id] && userSubmissions[task._id].length > 0 && ( | |
| <div className="bg-gradient-to-r from-white to-indigo-50 rounded-xl p-6 mb-6 border border-stone-200"> | |
| <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 className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 transition-all duration-300 ${ | |
| expandedSections[task._id] | |
| ? 'max-h-none overflow-visible' | |
| : 'max-h-0 overflow-hidden' | |
| }`}> | |
| {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">{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)} | |
| className="text-red-600 hover:text-red-800 text-sm font-medium" | |
| > | |
| Delete | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Translation Input (always show if user is logged in, but hide for image-only content) */} | |
| {localStorage.getItem('token') && task.content !== 'Image-based task' && task.imageAlignment !== 'portrait-split' && ( | |
| <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"> | |
| <DocumentTextIcon className="h-4 w-4 text-gray-600" /> | |
| </div> | |
| <h4 className="text-gray-900 font-semibold text-lg">Group Translation</h4> | |
| </div> | |
| {/* Group Selection */} | |
| <div className="mb-4"> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Select Your Group * | |
| </label> | |
| <select | |
| value={selectedGroups[task._id] || ''} | |
| onChange={(e) => setSelectedGroups({ ...selectedGroups, [task._id]: parseInt(e.target.value) })} | |
| className="w-48 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="mb-4"> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Your Group's Translation * | |
| </label> | |
| <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) => setTranslationText({ ...translationText, [task._id]: 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" | |
| rows={4} | |
| placeholder="Enter your group's translation here..." | |
| /> | |
| </div> | |
| <button | |
| onClick={() => handleSubmitTranslation(task._id)} | |
| disabled={submitting[task._id]} | |
| className="bg-indigo-500 hover:bg-indigo-600 disabled:bg-gray-400 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center transition-all duration-200" | |
| > | |
| {submitting[task._id] ? ( | |
| <> | |
| <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> | |
| Submitting... | |
| </> | |
| ) : ( | |
| <> | |
| Submit Group Translation | |
| <ArrowRightIcon className="h-4 w-4 ml-2" /> | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| )} | |
| {/* Show login message for visitors */} | |
| {!localStorage.getItem('token') && ( | |
| <div className="bg-gradient-to-r from-gray-50 to-indigo-50 rounded-xl p-6 border border-gray-200"> | |
| <div className="flex items-center space-x-2 mb-4"> | |
| <div className="bg-gray-100 rounded-full p-1"> | |
| <DocumentTextIcon className="h-4 w-4 text-gray-600" /> | |
| </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="bg-indigo-500 hover:bg-indigo-600 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center transition-all duration-200" | |
| > | |
| Go to Login | |
| <ArrowRightIcon className="h-4 w-4 ml-2" /> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| {/* Edit Submission Modal */} | |
| {editingSubmission && ( | |
| <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> | |
| <div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <h3 className="text-lg font-semibold text-gray-900">Edit Translation</h3> | |
| <button | |
| onClick={cancelEditSubmission} | |
| className="text-gray-400 hover:text-gray-600" | |
| > | |
| <XMarkIcon className="h-6 w-6" /> | |
| </button> | |
| </div> | |
| <div className="mb-4"> | |
| <textarea | |
| value={editSubmissionText} | |
| onChange={(e) => setEditSubmissionText(e.target.value)} | |
| className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" | |
| rows={6} | |
| placeholder="Enter your translation..." | |
| /> | |
| </div> | |
| <div className="flex justify-end space-x-3"> | |
| <button | |
| onClick={cancelEditSubmission} | |
| className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg" | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| onClick={saveEditedSubmission} | |
| className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700" | |
| > | |
| Save Changes | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default TutorialTasks; |