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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Auto-linker: supports [label](url), plain URLs, and www.* without touching existing href attributes
const html = escape(text)
// Markdown-style links: [label](https://example.com)
.replace(/\[([^\]]+?)\]\((https?:\/\/[^\s)]+)\)/g, '<a class="text-indigo-600 underline" href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
// Plain URLs with protocol, avoid matching inside attributes (require a non-attribute preceding char)
.replace(/(^|[^=\"'\/:])(https?:\/\/[^\s<]+)/g, (m, p1, url) => `${p1}<a class="text-indigo-600 underline" href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`)
// www.* domains (prepend https://), also avoid attributes
.replace(/(^|[^=\"'\/:])(www\.[^\s<]+)/g, (m, p1, host) => `${p1}<a class="text-indigo-600 underline" href="https://${host}" target="_blank" rel="noopener noreferrer">${host}</a>`)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\n/g, '<br/>');
return <span dangerouslySetInnerHTML={{ __html: html }} />;
};
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;