TransHub / client /src /pages /WeeklyPractice.tsx
linguabot's picture
Upload folder using huggingface_hub
5a11b0a verified
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../services/api';
import {
BookOpenIcon,
DocumentTextIcon,
CheckCircleIcon,
ClockIcon,
ArrowRightIcon,
PencilIcon,
XMarkIcon,
CheckIcon,
PlusIcon,
TrashIcon,
PlayIcon,
PauseIcon,
ArrowLeftIcon
} from '@heroicons/react/24/outline';
interface WeeklyPractice {
_id: string;
content: string;
weekNumber: number;
translationBrief?: string;
imageUrl?: string;
imageAlt?: string;
imageSize?: number;
imageAlignment?: 'left' | 'center' | 'right' | 'portrait-split';
}
interface WeeklyPracticeWeek {
weekNumber: number;
translationBrief?: string;
practices: WeeklyPractice[];
}
interface UserSubmission {
_id: string;
transcreation: string;
status: string;
score: number;
isOwner?: boolean;
userId?: {
_id: string;
username: string;
};
voteCounts: {
'1': number;
'2': number;
'3': number;
};
isAnonymous?: boolean;
}
// Subtitling interfaces
interface SubtitleSegment {
id: number;
startTime: string;
endTime: string;
duration: string;
sourceText: string;
targetText?: string;
isCurrent?: boolean;
}
interface VideoInfo {
title: string;
duration: string;
totalSegments: number;
currentSegment: number;
}
interface SubtitleSubmission {
_id: string;
username: string;
chineseTranslation: string;
submissionDate: string;
isAnonymous: boolean;
status?: string;
notes?: string;
}
const WeeklyPractice: React.FC = () => {
const [selectedWeek, setSelectedWeek] = useState<number>(() => {
const saved = localStorage.getItem('selectedWeeklyPracticeWeek');
return saved ? parseInt(saved) : 1;
});
const [isWeekTransitioning, setIsWeekTransitioning] = useState(false);
const [weeklyPractice, setWeeklyPractice] = useState<WeeklyPractice[]>([]);
const [weeklyPracticeWeek, setWeeklyPracticeWeek] = useState<WeeklyPracticeWeek | null>(null);
const [isWeekHidden, setIsWeekHidden] = useState<boolean>(false);
const [userSubmissions, setUserSubmissions] = useState<{[key: string]: UserSubmission[]}>({});
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState<{[key: string]: boolean}>({});
const [translationText, setTranslationText] = useState<{[key: string]: string}>({});
const [anonymousSubmissions, setAnonymousSubmissions] = useState<{[key: string]: boolean}>({});
const [expandedSections, setExpandedSections] = useState<{[key: string]: boolean}>({});
// Basic inline formatting helpers for weeks 4–6 (bold/italic via simple markdown) + links
const renderFormatted = (text: string) => {
const escape = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const html = escape(text)
.replace(/\[([^\]]+?)\]\((https?:\/\/[^\s)]+)\)/g, '<a class="text-pink-600 underline" href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/(^|[^=\"'\/:])(https?:\/\/[^\s<]+)/g, (m, p1, url) => `${p1}<a class=\"text-pink-600 underline\" href=\"${url}\" target=\"_blank\" rel=\"noopener noreferrer\">${url}</a>`)
.replace(/(^|[^=\"'\/:])(www\.[^\s<]+)/g, (m, p1, host) => `${p1}<a class=\"text-pink-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 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 applyLinkFormat = (
elementId: string,
current: string,
setValue: (v: string) => void
) => {
const urlInput = window.prompt('Enter URL (e.g., https://example.com):');
if (!urlInput) return;
let url = /^https?:\/\//i.test(urlInput) ? urlInput : `https://${urlInput}`;
url = url.replace(/["'>)\s]+$/g, '');
const el = document.getElementById(elementId) as HTMLTextAreaElement | null;
if (!el) {
setValue(`${current}[link](${url})`);
return;
}
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? current.length;
const before = current.slice(0, start);
const selection = current.slice(start, end) || 'link';
const after = current.slice(end);
setValue(`${before}[${selection}](${url})${after}`);
setTimeout(() => {
try { el.focus(); el.setSelectionRange(end, end); } catch {}
}, 0);
};
// Fetch visibility for current week (admin use only)
useEffect(() => {
const loadVisibility = async () => {
try {
const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, '');
const resp = await fetch(`${base}/api/admin/weeks/weekly-practice/${selectedWeek}/visibility`, {
headers: {
'Authorization': localStorage.getItem('token') ? `Bearer ${localStorage.getItem('token')}` : '',
'user-role': 'admin'
}
});
const json = await resp.json().catch(() => ({}));
setIsWeekHidden(!!json?.week?.hidden);
} catch (e) { /* noop */ }
};
loadVisibility();
}, [selectedWeek]);
const [editingPractice, setEditingPractice] = useState<string | null>(null);
const [editingBrief, setEditingBrief] = useState<{[key: number]: boolean}>({});
const [addingPractice, setAddingPractice] = 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 [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadingSource, setUploadingSource] = useState(false);
const [uploadingTranslation, setUploadingTranslation] = useState(false);
const [weekFiles, setWeekFiles] = useState<{ source: any[]; translation: any[] }>({ source: [], translation: [] });
const [showAllTranslations, setShowAllTranslations] = useState(false);
const navigate = useNavigate();
// Subtitling state for Week 2
const [currentSegment, setCurrentSegment] = useState<number>(1);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<string>('00:00');
const [totalTime, setTotalTime] = useState<string>('01:26');
const [subtitleText, setSubtitleText] = useState<string>('Am I a bad person?');
const [targetText, setTargetText] = useState<string>('');
const [characterCount, setCharacterCount] = useState<number>(0);
const [showTranslatedSubtitles, setShowTranslatedSubtitles] = useState<boolean>(false);
const [subtitleTranslations, setSubtitleTranslations] = useState<{[key: number]: string}>({});
const [editingTimeCodes, setEditingTimeCodes] = useState<boolean>(false);
const [editingSegment, setEditingSegment] = useState<number | null>(null);
const [currentVideoTime, setCurrentVideoTime] = useState<number>(0);
const [currentDisplayedSubtitle, setCurrentDisplayedSubtitle] = useState<string>('');
// Submissions viewing state
const [showSubmissions, setShowSubmissions] = useState(false);
const [submissions, setSubmissions] = useState<SubtitleSubmission[]>([]);
const [loadingSubmissions, setLoadingSubmissions] = useState(false);
const [submissionCount, setSubmissionCount] = useState(0);
const weeks = [1, 2, 3, 4, 5];
const isAdmin = (() => {
try {
const viewMode = (localStorage.getItem('viewMode')||'auto');
const role = JSON.parse(localStorage.getItem('user')||'{}').role;
return (viewMode !== 'student') && role === 'admin';
} catch { return false; }
})();
const handleWeekChange = async (week: number) => {
setIsWeekTransitioning(true);
// Clear existing data immediately
setWeeklyPractice([]);
setWeeklyPracticeWeek(null);
setUserSubmissions({});
localStorage.setItem('selectedWeeklyPracticeWeek', week.toString());
setSelectedWeek(week);
try {
// Explicitly fetch for the target week to avoid stale selectedWeek race
await fetchWeeklyPracticeForWeek(week, false);
const delay = week === 2 ? 400 : 200;
await new Promise(resolve => setTimeout(resolve, delay));
} catch (error) {
console.error('Error loading week data:', error);
} finally {
setIsWeekTransitioning(false);
}
};
// Authenticated file download helper
const downloadWeekFile = async (fileId: string, fallbackName?: string) => {
try {
const response = await api.get(`/api/weekly-practice-files/${fileId}/download`, { responseType: 'blob' });
// Try to extract filename from Content-Disposition
const disposition = (response.headers?.['content-disposition'] as string) || '';
let fileName = fallbackName || 'download';
try {
const starMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i);
const plainMatch = disposition.match(/filename="([^"\\]+)"/i);
if (starMatch && starMatch[1]) fileName = decodeURIComponent(starMatch[1]);
else if (plainMatch && plainMatch[1]) fileName = plainMatch[1];
} catch {}
const blobUrl = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = blobUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch (e) {
console.error('Failed to download file', e);
}
};
// Subtitle data state - will be loaded from database
const [subtitleSegments, setSubtitleSegments] = useState<SubtitleSegment[]>([]);
const [loadingSubtitles, setLoadingSubtitles] = useState<boolean>(true);
const videoInfo: VideoInfo = {
title: 'Nike \'Winning Isn\'t for Everyone\'',
duration: '1:26',
totalSegments: 26,
currentSegment: 1
};
// Subtitling functions
const handleSegmentClick = (segmentId: number) => {
setCurrentSegment(segmentId);
const segment = subtitleSegments.find(s => s.id === segmentId);
if (segment) {
setSubtitleText(segment.sourceText);
setTargetText(subtitleTranslations[segmentId] || '');
setCharacterCount(subtitleTranslations[segmentId]?.length || 0);
// Seek video to segment start time and set end time
const video = document.getElementById('nike-video') as HTMLVideoElement;
if (video) {
const startTime = parseTimeToSeconds(segment.startTime);
const endTime = parseTimeToSeconds(segment.endTime);
video.currentTime = startTime;
video.play();
// Stop video at segment end
const checkEndTime = () => {
if (video.currentTime >= endTime) {
video.pause();
video.removeEventListener('timeupdate', checkEndTime);
}
};
video.addEventListener('timeupdate', checkEndTime);
}
}
// Fetch submissions for this segment if submissions are shown
if (showSubmissions) {
fetchSubmissionsForSegment(segmentId);
}
};
const parseTimeToSeconds = (timeString: string): number => {
// Parse time format like "00:00:03,000" to seconds
const parts = timeString.split(':');
const secondsAndMs = parts[2].split(',');
const seconds = parseInt(secondsAndMs[0]);
const milliseconds = parseInt(secondsAndMs[1] || '0');
const minutes = parseInt(parts[1]);
const hours = parseInt(parts[0]);
return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000;
};
const formatSubtitleForDisplay = (text: string): string => {
// Format subtitle text for on-screen display with 60-character limit per line
const maxCharsPerLine = 60;
// First, replace any \n characters with spaces to normalize the text
const normalizedText = text.replace(/\n/g, ' ');
// If normalized text is 42 characters or less, keep it on a single line
if (normalizedText.length <= maxCharsPerLine) {
return normalizedText;
}
// For longer text, wrap at word boundaries
const words = normalizedText.split(' ');
const lines: string[] = [];
let currentLine = '';
words.forEach(word => {
// If adding this word would exceed the limit
if ((currentLine + ' ' + word).length > maxCharsPerLine) {
// If current line is not empty, save it and start a new line
if (currentLine) {
lines.push(currentLine);
currentLine = word;
} else {
// If current line is empty and word is too long, split the word
if (word.length > maxCharsPerLine) {
// Split long word if necessary
const firstPart = word.substring(0, maxCharsPerLine);
const secondPart = word.substring(maxCharsPerLine);
lines.push(firstPart);
currentLine = secondPart;
} else {
currentLine = word;
}
}
} else {
// Add word to current line
currentLine += (currentLine ? ' ' : '') + word;
}
});
if (currentLine) {
lines.push(currentLine);
}
return lines.join('\n');
};
const updateSubtitleDisplay = (currentTime: number) => {
console.log('🔧 Updating subtitle display at time:', currentTime);
console.log('🔧 Available segments:', subtitleSegments.length);
// Find the current segment with 0.5s buffer
const currentSegment = subtitleSegments.find(segment => {
const startTime = parseTimeToSeconds(segment.startTime);
const endTime = parseTimeToSeconds(segment.endTime);
// Add 0.5s buffer to keep subtitle visible longer
const isInRange = currentTime >= startTime && currentTime <= (endTime + 0.5);
console.log(`🔧 Segment ${segment.id}: ${startTime}s - ${endTime + 0.5}s, in range: ${isInRange}`);
return isInRange;
});
if (currentSegment) {
let subtitleText = currentSegment.sourceText;
if (showTranslatedSubtitles && subtitleTranslations[currentSegment.id]) {
subtitleText = subtitleTranslations[currentSegment.id];
}
// Format the subtitle text for proper display
const formattedText = formatSubtitleForDisplay(subtitleText);
console.log('🔧 Setting subtitle text:', formattedText);
setCurrentDisplayedSubtitle(formattedText);
} else {
// Clear subtitle when no segment is found
console.log('🔧 No segment found for current time, clearing subtitle');
setCurrentDisplayedSubtitle('');
}
};
// Video time tracking effect
React.useEffect(() => {
const video = document.getElementById('nike-video') as HTMLVideoElement;
if (!video) {
console.log('🔧 Video element not found');
return;
}
console.log('🔧 Setting up video time tracking');
console.log('🔧 Current subtitle segments:', subtitleSegments.length);
const handleTimeUpdate = () => {
const currentTime = video.currentTime;
setCurrentVideoTime(currentTime);
updateSubtitleDisplay(currentTime);
};
video.addEventListener('timeupdate', handleTimeUpdate);
return () => video.removeEventListener('timeupdate', handleTimeUpdate);
}, [showTranslatedSubtitles, subtitleTranslations, subtitleSegments]);
const handlePlayPause = () => {
setIsPlaying(!isPlaying);
};
const handleTargetTextChange = (text: string) => {
setTargetText(text);
setCharacterCount(text.length);
};
const handleSaveAndNext = () => {
// Save current translation
console.log('Saving translation for segment:', currentSegment, targetText);
// Move to next segment
if (currentSegment < subtitleSegments.length) {
handleSegmentClick(currentSegment + 1);
}
};
const handlePrevious = () => {
if (currentSegment > 1) {
handleSegmentClick(currentSegment - 1);
}
};
const handlePreviewTranslation = () => {
// Toggle between original and translated subtitles
const newShowTranslated = !showTranslatedSubtitles;
setShowTranslatedSubtitles(newShowTranslated);
// Immediately update the displayed subtitle
if (currentSegment > 0 && subtitleSegments[currentSegment - 1]) {
const segment = subtitleSegments[currentSegment - 1];
let subtitleText = segment.sourceText;
if (newShowTranslated && subtitleTranslations[currentSegment]) {
subtitleText = subtitleTranslations[currentSegment];
}
// Format the subtitle text for proper display
const formattedText = formatSubtitleForDisplay(subtitleText);
setCurrentDisplayedSubtitle(formattedText);
console.log('🔄 Preview toggle:', newShowTranslated ? 'Translated' : 'Original', formattedText);
}
};
const [saveStatus, setSaveStatus] = useState<string>('Save');
const handleSaveTranslation = async () => {
try {
setSaveStatus('Saving...');
// Save to local state immediately
setSubtitleTranslations(prev => ({
...prev,
[currentSegment]: targetText
}));
console.log('💾 Saving translation for segment:', currentSegment, targetText);
// Try to save to database
try {
const response = await api.put(`/api/subtitles/translate/${currentSegment}`, {
chineseTranslation: targetText
});
if (response.data.success) {
console.log('✅ Translation saved to database successfully');
setSaveStatus('Saved!');
setTimeout(() => setSaveStatus('Save'), 2000);
}
} catch (dbError: any) {
console.warn('⚠️ Database save failed, but local changes applied:', dbError.message);
setSaveStatus('Saved');
setTimeout(() => setSaveStatus('Save'), 2000);
}
// Also save as a submission
try {
const user = JSON.parse(localStorage.getItem('user') || '{}');
console.log('🔧 Submitting translation as submission for user:', user.username);
// Make sure we have a valid username
const username = user.username || user.name || 'Unknown User';
console.log('🔧 Using username for submission:', username);
const submissionResponse = await api.post('/api/subtitle-submissions/submit', {
segmentId: currentSegment,
chineseTranslation: targetText,
isAnonymous: false,
weekNumber: 2,
username: username
});
if (submissionResponse.data.success) {
console.log('✅ Translation submitted successfully');
// Refresh submissions if they're currently shown
if (showSubmissions) {
await fetchSubmissionsForSegment(currentSegment);
}
}
} catch (submissionError: any) {
console.error('❌ Submission save failed:', submissionError.response?.data || submissionError.message);
}
// Keep the translation in the input field after saving
// setTargetText(''); // Removed - keep translation visible
// setCharacterCount(0); // Removed - keep character count
} catch (error: any) {
console.error('❌ Error saving translation:', error);
setSaveStatus('Error');
setTimeout(() => setSaveStatus('Save'), 2000);
}
};
const handleEditTimeCode = (segmentId: number) => {
setEditingSegment(segmentId);
};
const handleSaveTimeCode = async (segmentId: number, startTime: string, endTime: string) => {
try {
// Check if user is admin (for UI purposes)
const user = JSON.parse(localStorage.getItem('user') || '{}');
console.log('🔧 Debug - User:', user);
if (user.role !== 'admin') {
console.error('❌ User is not admin');
return;
}
// Calculate duration
const startSeconds = parseTimeToSeconds(startTime);
const endSeconds = parseTimeToSeconds(endTime);
const durationMs = (endSeconds - startSeconds) * 1000;
const duration = `${Math.floor(durationMs / 1000)}s ${durationMs % 1000}ms`;
console.log('🔧 Updating time codes for segment:', segmentId);
console.log('🔧 New times:', { startTime, endTime, duration });
// Update local state immediately for UI responsiveness
setSubtitleSegments(prev => prev.map(segment =>
segment.id === segmentId
? { ...segment, startTime, endTime, duration }
: segment
));
// Try to update database
try {
const response = await api.put(`/api/subtitles/update/${segmentId}`, {
startTime,
endTime,
duration,
reason: 'Time code update'
});
if (response.data.success) {
console.log('✅ Time codes updated in database successfully');
}
} catch (dbError: any) {
console.warn('⚠️ Database update failed, but local changes applied:', dbError.message);
}
} catch (error: any) {
console.error('❌ Error updating time codes:', error);
}
setEditingSegment(null);
};
// Fetch subtitles from database
const fetchSubtitles = async () => {
try {
setLoadingSubtitles(true);
console.log('🔧 Fetching subtitles from database...');
const response = await api.get('/api/subtitles/all');
console.log('🔧 Subtitle response:', response.data);
if (response.data.success) {
const subtitles = response.data.data.map((subtitle: any) => ({
id: subtitle.segmentId,
startTime: subtitle.startTime,
endTime: subtitle.endTime,
duration: subtitle.duration,
sourceText: subtitle.englishText
}));
console.log('🔧 Processed subtitles:', subtitles.length);
setSubtitleSegments(subtitles);
// Load existing translations from database
const translations: { [key: number]: string } = {};
response.data.data.forEach((subtitle: any) => {
if (subtitle.chineseTranslation) {
translations[subtitle.segmentId] = subtitle.chineseTranslation;
}
});
console.log('🔧 Loaded translations:', Object.keys(translations).length);
setSubtitleTranslations(translations);
} else {
console.error('❌ Subtitle fetch failed:', response.data);
}
} catch (error) {
console.error('❌ Error fetching subtitles:', error);
// Fallback to hardcoded data if database fails
console.log('🔧 Using fallback subtitle data...');
const fallbackSubtitles = [
{ id: 1, startTime: '00:00:00,640', endTime: '00:00:02,400', duration: '1s 760ms', sourceText: 'Am I a bad person?' },
{ id: 2, startTime: '00:00:06,320', endTime: '00:00:07,860', duration: '1s 540ms', sourceText: 'Tell me. Am I?' },
{ id: 3, startTime: '00:00:08,480', endTime: '00:00:09,740', duration: '1s 260ms', sourceText: 'I\'m single minded.' },
{ id: 4, startTime: '00:00:10,570', endTime: '00:00:11,780', duration: '1s 210ms', sourceText: 'I\'m deceptive.' },
{ id: 5, startTime: '00:00:12,050', endTime: '00:00:13,490', duration: '1s 440ms', sourceText: 'I\'m obsessive.' },
{ id: 6, startTime: '00:00:13,780', endTime: '00:00:14,910', duration: '1s 130ms', sourceText: 'I\'m selfish.' },
{ id: 7, startTime: '00:00:15,120', endTime: '00:00:17,200', duration: '2s 80ms', sourceText: 'Does that make me a bad person?' },
{ id: 8, startTime: '00:00:18,010', endTime: '00:00:19,660', duration: '1s 650ms', sourceText: 'Am I a bad person?' },
{ id: 9, startTime: '00:00:20,870', endTime: '00:00:21,870', duration: '1s 0ms', sourceText: 'Am I?' },
{ id: 10, startTime: '00:00:23,120', endTime: '00:00:24,390', duration: '1s 270ms', sourceText: 'I have no empathy.' },
{ id: 11, startTime: '00:00:25,540', endTime: '00:00:27,170', duration: '1s 630ms', sourceText: 'I don\'t respect you.' },
{ id: 12, startTime: '00:00:28,550', endTime: '00:00:29,880', duration: '1s 330ms', sourceText: 'I\'m never satisfied.' },
{ id: 13, startTime: '00:00:30,440', endTime: '00:00:33,180', duration: '2s 740ms', sourceText: 'I have an obsession with power.' },
{ id: 14, startTime: '00:00:37,850', endTime: '00:00:38,950', duration: '1s 100ms', sourceText: 'I\'m irrational.' },
{ id: 15, startTime: '00:00:39,930', endTime: '00:00:41,520', duration: '1s 590ms', sourceText: 'I have zero remorse.' },
{ id: 16, startTime: '00:00:41,770', endTime: '00:00:43,900', duration: '2s 130ms', sourceText: 'I have no sense of compassion.' },
{ id: 17, startTime: '00:00:44,480', endTime: '00:00:46,650', duration: '2s 170ms', sourceText: 'I\'m delusional. I\'m maniacal.' },
{ id: 18, startTime: '00:00:46,960', endTime: '00:00:48,980', duration: '2s 20ms', sourceText: 'You think I\'m a bad person?' },
{ id: 19, startTime: '00:00:49,320', endTime: '00:00:52,700', duration: '3s 380ms', sourceText: 'Tell me. Tell me. Tell me.\nTell me. Am I?' },
{ id: 20, startTime: '00:00:52,990', endTime: '00:00:55,136', duration: '2s 146ms', sourceText: 'I think I\'m better than everyone else.' },
{ id: 21, startTime: '00:00:55,170', endTime: '00:00:57,820', duration: '2s 650ms', sourceText: 'I want to take what\'s yours\nand never give it back.' },
{ id: 22, startTime: '00:00:57,840', endTime: '00:01:00,640', duration: '2s 800ms', sourceText: 'What\'s mine is mine\nand what\'s yours is mine.' },
{ id: 23, startTime: '00:01:06,920', endTime: '00:01:08,290', duration: '1s 370ms', sourceText: 'Am I a bad person?' },
{ id: 24, startTime: '00:01:08,840', endTime: '00:01:10,420', duration: '1s 580ms', sourceText: 'Tell me. Am I?' },
{ id: 25, startTime: '00:01:21,500', endTime: '00:01:23,650', duration: '2s 150ms', sourceText: 'Does that make me a bad person?' },
{ id: 26, startTime: '00:01:25,060', endTime: '00:01:26,900', duration: '1s 840ms', sourceText: 'Tell me. Does it?' },
];
setSubtitleSegments(fallbackSubtitles);
} finally {
setLoadingSubtitles(false);
}
};
// Fetch submissions for a specific segment
const fetchSubmissionsForSegment = async (segmentId: number) => {
try {
setLoadingSubmissions(true);
console.log('🔧 Fetching submissions for segment:', segmentId);
// Add timeout to prevent long loading states
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Request timeout')), 5000)
);
const response = await Promise.race([
api.get(`/api/subtitle-submissions/segment/${segmentId}?week=2`),
timeoutPromise
]) as any;
if (response.data.success) {
setSubmissions(response.data.data);
setSubmissionCount(response.data.count);
console.log('✅ Fetched submissions:', response.data.count);
} else {
console.error('❌ Failed to fetch submissions:', response.data);
setSubmissions([]);
setSubmissionCount(0);
}
} catch (error) {
console.error('❌ Error fetching submissions:', error);
setSubmissions([]);
setSubmissionCount(0);
} finally {
setLoadingSubmissions(false);
}
};
// Toggle submissions panel
const toggleSubmissions = async () => {
if (!showSubmissions) {
// Show submissions and fetch data
setShowSubmissions(true);
await fetchSubmissionsForSegment(currentSegment);
} else {
// Hide submissions
setShowSubmissions(false);
}
};
// Delete subtitle submission (admin only)
const handleDeleteSubtitleSubmission = async (submissionId: string) => {
try {
console.log('🗑️ Deleting subtitle submission:', submissionId);
// Show loading state on the delete button
const deleteButton = document.querySelector(`[data-submission-id="${submissionId}"]`) as HTMLButtonElement;
if (deleteButton) {
deleteButton.innerHTML = '⏳';
deleteButton.disabled = true;
}
const response = await api.delete(`/api/subtitle-submissions/${submissionId}`);
if (response.data.success) {
console.log('✅ Subtitle submission deleted successfully');
// Refresh submissions if they're currently shown
if (showSubmissions) {
await fetchSubmissionsForSegment(currentSegment);
}
}
} catch (error: any) {
console.error('❌ Error deleting subtitle submission:', error.response?.data || error.message);
} finally {
// Restore delete button
const deleteButton = document.querySelector(`[data-submission-id="${submissionId}"]`) as HTMLButtonElement;
if (deleteButton) {
deleteButton.innerHTML = '🗑️';
deleteButton.disabled = false;
}
}
};
// Load subtitles when component mounts
React.useEffect(() => {
if (selectedWeek === 2) {
fetchSubtitles();
}
}, [selectedWeek]);
// Debug: Log subtitle segments when they change
React.useEffect(() => {
console.log('🔧 Subtitle segments updated:', subtitleSegments.length);
if (subtitleSegments.length > 0) {
console.log('🔧 First segment:', subtitleSegments[0]);
}
}, [subtitleSegments]);
const getSegmentStatus = (segmentId: number) => {
if (segmentId === currentSegment) return 'current';
return 'pending';
};
const getSegmentButtonClass = (segmentId: number) => {
const status = getSegmentStatus(segmentId);
const isSaved = subtitleTranslations[segmentId];
switch (status) {
case 'current':
return 'bg-pink-600 text-white';
default:
return isSaved
? 'bg-pink-100 text-pink-700 border border-pink-300'
: 'bg-gray-100 text-gray-700 border border-gray-300';
}
};
const handleFileUpload = async (file: File): Promise<string> => {
try {
setUploading(true);
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
console.log('File uploaded:', file.name, 'Size:', file.size);
console.log('Generated data URL:', dataUrl.substring(0, 50) + '...');
resolve(dataUrl);
};
reader.onerror = () => {
console.error('Error reading file:', reader.error);
reject(reader.error);
};
reader.readAsDataURL(file);
});
} catch (error) {
console.error('Error uploading file:', error);
throw error;
} finally {
setUploading(false);
}
};
const toggleExpanded = (practiceId: string) => {
setExpandedSections(prev => ({
...prev,
[practiceId]: !prev[practiceId]
}));
};
const fetchUserSubmissions = useCallback(async (practice: WeeklyPractice[]) => {
try {
const response = await api.get('/api/submissions/my-submissions');
if (response.data) {
const data = response.data;
const groupedSubmissions: {[key: string]: UserSubmission[]} = {};
// Initialize all practices with empty arrays
practice.forEach(practice => {
groupedSubmissions[practice._id] = [];
});
// Then populate with actual submissions and mark ownership for edit visibility after refresh/login
practice.forEach(practice => {
const practiceSubmissions = data.submissions
.filter((sub: any) => sub.sourceTextId && sub.sourceTextId._id === practice._id)
.map((sub: any) => ({ ...sub, isOwner: true }));
if (practiceSubmissions.length > 0) {
groupedSubmissions[practice._id] = practiceSubmissions;
}
});
setUserSubmissions(groupedSubmissions);
}
} catch (error) {
console.error('Error fetching user submissions:', error);
}
}, []);
// Fetch for current selectedWeek
const fetchWeeklyPractice = useCallback(async (showLoading = true) => {
try {
if (showLoading) { setLoading(true); }
const response = await api.get(`/api/search/weekly-practice/${selectedWeek}`);
if (response.data) {
const practices = response.data;
setWeeklyPractice(practices);
// Organize practices into week structure
if (practices.length > 0) {
let translationBrief = practices[0].translationBrief;
// Fallback to explicit week brief API if missing
if (!translationBrief) {
try {
const br = await api.get(`/api/search/weekly-practice/${selectedWeek}/brief`);
translationBrief = br.data?.translationBrief || '';
} catch {}
}
const weeklyPracticeWeekData: WeeklyPracticeWeek = {
weekNumber: selectedWeek,
translationBrief: translationBrief,
practices: practices
};
setWeeklyPracticeWeek(weeklyPracticeWeekData);
} else {
// No practices: still fetch brief, but do not surface brief for students when hidden (server already returns empty when hidden)
try {
const br = await api.get(`/api/search/weekly-practice/${selectedWeek}/brief`);
const viewMode = (localStorage.getItem('viewMode')||'auto');
const role = (JSON.parse(localStorage.getItem('user')||'{}').role);
const isAdminView = (viewMode !== 'student') && role === 'admin';
setWeeklyPracticeWeek({ weekNumber: selectedWeek, translationBrief: isAdminView ? (br.data?.translationBrief || '') : (br.data?.translationBrief || ''), practices: [] });
} catch {
setWeeklyPracticeWeek(null);
}
}
await fetchUserSubmissions(practices);
} else {
console.error('Failed to fetch weekly practice');
}
} catch (error) {
console.error('Error fetching weekly practice:', error);
} finally {
if (showLoading) { setLoading(false); }
}
}, [selectedWeek, fetchUserSubmissions]);
// Fetch files list for current week (source + translation)
const fetchWeekFiles = useCallback(async () => {
try {
const [src, trn] = await Promise.all([
api.get(`/api/weekly-practice-files/week/${selectedWeek}?type=source`),
api.get(`/api/weekly-practice-files/week/${selectedWeek}?type=translation`)
]);
setWeekFiles({ source: src.data.files || [], translation: trn.data.files || [] });
} catch (e) {
console.error('Failed to load week files', e);
setWeekFiles({ source: [], translation: [] });
}
}, [selectedWeek]);
// Fetch for a specific week (avoids stale week race when changing weeks)
const fetchWeeklyPracticeForWeek = useCallback(async (week: number, showLoading = true) => {
try {
if (showLoading) { setLoading(true); }
const response = await api.get(`/api/search/weekly-practice/${week}`);
if (response.data) {
const practices = response.data;
setWeeklyPractice(practices);
if (practices.length > 0) {
const translationBrief = practices[0].translationBrief;
const weeklyPracticeWeekData: WeeklyPracticeWeek = {
weekNumber: week,
translationBrief: translationBrief,
practices: practices
};
setWeeklyPracticeWeek(weeklyPracticeWeekData);
} else {
setWeeklyPracticeWeek(null);
}
await fetchUserSubmissions(practices);
} else {
console.error('Failed to fetch weekly practice');
}
} catch (error) {
console.error('Error fetching weekly practice (explicit week):', error);
} finally {
if (showLoading) { setLoading(false); }
}
}, [fetchUserSubmissions]);
useEffect(() => {
const user = localStorage.getItem('user');
if (!user) {
navigate('/login');
return;
}
fetchWeeklyPractice();
fetchWeekFiles();
}, [fetchWeeklyPractice, fetchWeekFiles, navigate]);
// Upload handlers for week files
const uploadSourceFile = async (file: File, description?: string) => {
try {
setUploadingSource(true);
const form = new FormData();
form.append('file', file);
if (description) form.append('description', description);
await api.post(`/api/weekly-practice-files/week/${selectedWeek}/source`, form, {
headers: { 'Content-Type': 'multipart/form-data' }
});
await fetchWeekFiles();
} finally {
setUploadingSource(false);
}
};
const uploadTranslationFile = async (file: File, description?: string, sourceFileId?: string) => {
try {
setUploadingTranslation(true);
const form = new FormData();
form.append('file', file);
if (description) form.append('description', description);
if (sourceFileId) form.append('sourceFileId', sourceFileId);
await api.post(`/api/weekly-practice-files/week/${selectedWeek}/translation`, form, {
headers: { 'Content-Type': 'multipart/form-data' }
});
await fetchWeekFiles();
} finally {
setUploadingTranslation(false);
}
};
// Listen for week reset events from page navigation
useEffect(() => {
const handleWeekReset = (event: CustomEvent) => {
if (event.detail.page === 'weekly-practice') {
console.log('Week reset event received for weekly practice');
setSelectedWeek(event.detail.week);
localStorage.setItem('selectedWeeklyPracticeWeek', 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 && weeklyPractice.length > 0) {
fetchUserSubmissions(weeklyPractice);
}
}, [weeklyPractice, fetchUserSubmissions]);
const handleSubmitTranslation = async (practiceId: string) => {
if (!translationText[practiceId]?.trim()) {
alert('Please provide a translation');
return;
}
try {
setSubmitting({ ...submitting, [practiceId]: true });
const token = localStorage.getItem('token');
const user = JSON.parse(localStorage.getItem('user') || '{}');
const response = await api.post('/api/submissions', {
sourceTextId: practiceId,
transcreation: translationText[practiceId],
culturalAdaptations: [],
isAnonymous: anonymousSubmissions[practiceId] || false,
username: user.name || 'Unknown'
});
if (response.status === 201 || response.status === 200) {
console.log('✅ Translation submitted successfully');
setTranslationText({ ...translationText, [practiceId]: '' });
await fetchUserSubmissions(weeklyPractice);
} else {
console.error('❌ Submission failed:', response.data);
}
} catch (error) {
console.error('Error submitting translation:', error);
} finally {
setSubmitting({ ...submitting, [practiceId]: 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 token = localStorage.getItem('token');
const response = await api.put(`/api/submissions/${editingSubmission.id}`, {
transcreation: editSubmissionText
});
if (response.status === 200) {
console.log('✅ Translation updated successfully');
setEditingSubmission(null);
setEditSubmissionText('');
await fetchUserSubmissions(weeklyPractice);
} else {
console.error('❌ Update failed:', 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(weeklyPractice);
} 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 = (practice: WeeklyPractice) => {
setEditingPractice(practice._id);
setEditForm({
content: practice.content,
translationBrief: '',
imageUrl: practice.imageUrl || '',
imageAlt: practice.imageAlt || ''
});
};
const startEditingBrief = () => {
setEditingBrief(prev => ({ ...prev, [selectedWeek]: true }));
setEditForm({
content: '',
translationBrief: weeklyPracticeWeek?.translationBrief || '',
imageUrl: '',
imageAlt: ''
});
};
const startAddingBrief = () => {
setEditingBrief(prev => ({ ...prev, [selectedWeek]: true }));
setEditForm({
content: '',
translationBrief: '',
imageUrl: '',
imageAlt: ''
});
};
const removeBrief = async () => {
try {
setSaving(true);
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/weekly-brief/${selectedWeek}`, {
translationBrief: '',
weekNumber: selectedWeek
});
if (response.data) {
await fetchWeeklyPractice(false);
} else {
console.error('Failed to remove translation brief');
}
} catch (error) {
console.error('Failed to remove translation brief:', error);
} finally {
setSaving(false);
}
};
const cancelEditing = () => {
setEditingPractice(null);
setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
setEditForm({
content: '',
translationBrief: '',
imageUrl: '',
imageAlt: ''
});
};
const savePractice = async () => {
if (!editingPractice) return;
try {
setSaving(true);
const user = JSON.parse(localStorage.getItem('user') || '{}');
// Check if user is admin
if (user.role !== 'admin') {
return;
}
const payload = {
...editForm,
weekNumber: selectedWeek,
imageUrl: editForm.imageUrl || null,
imageAlt: editForm.imageAlt || null
};
console.log('Saving practice with payload:', payload);
const response = await api.put(`/api/auth/admin/weekly-practice/${editingPractice}`, payload);
if (response.data) {
console.log('Practice updated successfully:', response.data);
await fetchWeeklyPractice(false);
setEditingPractice(null);
} else {
console.error('Failed to update practice');
}
} catch (error) {
console.error('Failed to update weekly practice:', 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;
}
const response = await api.put(`/api/auth/admin/weekly-brief/${selectedWeek}`, {
translationBrief: editForm.translationBrief,
weekNumber: selectedWeek
});
if (response.data) {
// Optimistic UI update
setWeeklyPracticeWeek(prev => prev ? { ...prev, translationBrief: editForm.translationBrief } : prev);
setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
// Background refresh
fetchWeeklyPractice(false);
} else {
console.error('Failed to update translation brief');
}
} catch (error) {
console.error('Failed to update translation brief:', error);
} finally {
setSaving(false);
}
};
const startAddingPractice = () => {
setAddingPractice(true);
setEditForm({
content: '',
translationBrief: '',
imageUrl: '',
imageAlt: ''
});
};
const cancelAddingPractice = () => {
setAddingPractice(false);
setEditForm({
content: '',
translationBrief: '',
imageUrl: '',
imageAlt: ''
});
};
const startAddingImage = () => {
setAddingImage(true);
setImageForm({
imageUrl: '',
imageAlt: '',
imageSize: 200,
imageAlignment: 'center'
});
};
const cancelAddingImage = () => {
setAddingImage(false);
setImageForm({
imageUrl: '',
imageAlt: '',
imageSize: 200,
imageAlignment: 'center'
});
};
const saveNewPractice = 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 image, but not both empty
console.log('🔧 Validation check:', {
content: editForm.content.trim(),
imageUrl: editForm.imageUrl.trim(),
contentLength: editForm.content.trim().length,
imageUrlLength: editForm.imageUrl.trim().length
});
if (!editForm.content.trim() && !editForm.imageUrl.trim()) {
console.log('❌ Both content and image are empty');
return;
}
const payload = {
title: `Week ${selectedWeek} Weekly Practice`,
content: editForm.content.trim() || (editForm.imageUrl.trim() ? 'Image-based practice' : ''),
sourceLanguage: 'English',
weekNumber: selectedWeek,
category: 'weekly-practice',
imageUrl: editForm.imageUrl.trim() || null,
imageAlt: editForm.imageAlt.trim() || null,
// Add imageSize and imageAlignment for image-only content
...(editForm.imageUrl.trim() && !editForm.content.trim() && { imageSize: 200 }),
...(editForm.imageUrl.trim() && !editForm.content.trim() && { imageAlignment: 'center' })
};
console.log('🔧 Saving new practice with payload:', payload);
console.log('🔧 Week number:', selectedWeek);
console.log('🔧 Image URL length:', editForm.imageUrl.trim().length);
const response = await api.post('/api/auth/admin/weekly-practice', payload);
if (response.data) {
console.log('Practice saved successfully:', response.data);
await fetchWeeklyPractice(false);
setAddingPractice(false);
} else {
console.error('Failed to save practice');
}
} catch (error) {
console.error('Failed to add weekly practice:', 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 Practice`,
content: '', // Empty content for image-only practice
sourceLanguage: 'English',
weekNumber: selectedWeek,
category: 'weekly-practice',
imageUrl: imageForm.imageUrl.trim(),
imageAlt: imageForm.imageAlt.trim() || null,
imageSize: imageForm.imageSize,
imageAlignment: imageForm.imageAlignment
};
console.log('Saving new image with payload:', payload);
const response = await api.post('/api/auth/admin/weekly-practice', payload);
if (response.data) {
console.log('Image saved successfully:', response.data);
// Optimistic UI update: append new practice for current week (4–6 only)
if (selectedWeek >= 4) {
const newPractice = response.data.weeklyPractice || response.data.practice || null;
if (newPractice) {
setWeeklyPractice(prev => [...prev, newPractice]);
setWeeklyPracticeWeek(prev => prev ? { ...prev, practices: [...prev.practices, newPractice] } : prev);
}
}
await fetchWeeklyPractice(false);
setAddingImage(false);
} else {
console.error('Failed to save image');
}
} catch (error) {
console.error('Failed to add image practice:', error);
} finally {
setSaving(false);
}
};
const deletePractice = async (practiceId: string) => {
const user = JSON.parse(localStorage.getItem('user') || '{}');
// Check if user is admin
if (user.role !== 'admin') {
return;
}
setSaving(true);
try {
if (String(practiceId).startsWith('wb-')) {
console.warn('Skipping deletion of week-brief placeholder');
setSaving(false);
return;
}
const response = await api.delete(`/api/auth/admin/weekly-practice/${practiceId}`);
if (response.data) {
setWeeklyPracticeWeek(prev => prev ? { ...prev, translationBrief: '' } : prev);
const briefKey = `weeklyBrief_week_${selectedWeek}`;
try { localStorage.removeItem(briefKey); } catch {}
fetchWeeklyPractice(false);
} else {
console.error('Failed to delete weekly practice');
}
} catch (error) {
console.error('Failed to delete weekly practice:', error);
} finally {
setSaving(false);
}
};
return (
<div className="min-h-screen bg-white py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center mb-4">
<img src="/icons/weekly practice.svg" alt="Weekly Practice" className="h-8 w-8 mr-3" />
<h1 className="text-3xl font-bold text-ui-text">Weekly Practice</h1>
</div>
<p className="text-ui-text/70">
Practice your translation skills with weekly examples and cultural elements.
</p>
</div>
{/* Week Selector */}
<div className="mb-6">
<div className="flex space-x-3 overflow-x-auto pb-2">
{weeks.map((week) => {
const isActive = selectedWeek === week;
return (
<button
key={week}
onClick={() => handleWeekChange(week)}
className={`relative inline-flex items-center justify-center rounded-2xl px-4 py-1.5 whitespace-nowrap transition-all duration-300 ease-out ring-1 ring-inset ${isActive ? 'ring-white/50 backdrop-brightness-110 backdrop-saturate-150' : 'ring-white/30'} backdrop-blur-md isolate shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)]`}
style={{ background: 'rgba(255,255,255,0.10)' }}
>
{/* Rim washes */}
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" />
{/* Soft glossy wash */}
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
{/* Tiny hotspot near TL */}
<div className="pointer-events-none absolute rounded-full" style={{ width: '28px', height: '28px', left: '8px', top: '6px', background: 'radial-gradient(16px_16px_at_10px_10px,rgba(255,255,255,0.5),rgba(255,255,255,0)_60%)', opacity: 0.45 }} />
{/* Center darken for depth on unselected only */}
{!isActive && (
<div className="pointer-events-none absolute inset-0 rounded-2xl" style={{ background: 'radial-gradient(120%_120%_at_50%_55%,rgba(0,0,0,0.04),rgba(0,0,0,0)_60%)', opacity: 0.9 }} />
)}
{/* Pink tint overlay to match Weekly Practice identity */}
<div className={`pointer-events-none absolute inset-0 rounded-2xl ${isActive ? 'bg-pink-600/70 mix-blend-normal opacity-100' : 'bg-pink-500/30 mix-blend-overlay opacity-35'}`} />
<span className={`relative z-10 text-sm font-medium ${isActive ? 'text-white' : 'text-black'}`}>Week {week}</span>
</button>
);
})}
</div>
{isAdmin && (
<div className="mt-2 flex items-center gap-2">
<span className="text-xs text-ui-text/70">Visibility</span>
<label className="switch">
<input type="checkbox" checked={isWeekHidden} onChange={async(e)=>{
try {
const base = (((api.defaults as any)?.baseURL as string)||'').replace(/\/$/,'');
// when checked => Hidden, unchecked => Shown
const nextHidden = e.currentTarget.checked;
await fetch(`${base}/api/admin/weeks/weekly-practice/${selectedWeek}/visibility`,{ method:'PUT', headers:{ 'Content-Type':'application/json', 'Authorization': localStorage.getItem('token')?`Bearer ${localStorage.getItem('token')}`:'', 'user-role':'admin' }, body: JSON.stringify({ hidden: nextHidden }) });
setIsWeekHidden(nextHidden);
await fetchWeeklyPractice(true);
} catch (err) { console.error(err);}
}}/>
<span className="slider" />
</label>
<span className="text-xs">{isWeekHidden ? 'Hidden' : 'Shown'}</span>
</div>
)}
</div>
{/* Week Transition Loading Spinner */}
{isWeekTransitioning && (
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50">
<div className="bg-ui-panel rounded-lg shadow-lg p-4 flex items-center space-x-3 border border-ui-border">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-pink-500"></div>
<span className="text-ui-text font-medium">Loading...</span>
</div>
</div>
)}
{/* Translation Brief - Shown once at the top (hidden for students when week is hidden) */}
{!isWeekTransitioning && weeklyPracticeWeek && weeklyPracticeWeek.translationBrief && (isAdmin || !isWeekHidden) ? (
<div className="bg-ui-panel rounded-xl p-8 mb-8 border border-ui-border">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<div className="bg-ui-bg rounded-full p-2">
<BookOpenIcon className="h-5 w-5 text-pink-500" />
</div>
<h3 className="text-ui-text font-semibold text-xl">Translation Brief</h3>
</div>
{(localStorage.getItem('viewMode')||'auto') === 'student' ? false : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin') && (
<div className="flex items-center space-x-2">
{editingBrief[selectedWeek] ? (
<>
<button
onClick={saveBrief}
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={startEditingBrief}
className="bg-ui-bg hover:bg-white text-ui-text px-3 py-1 rounded-lg transition-colors duration-200"
>
<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-lg transition-colors duration-200"
>
<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('weekly-brief-input', editForm.translationBrief, v => setEditForm({ ...editForm, translationBrief: v }), '**')} className="px-2 py-1 text-xs bg-ui-bg text-ui-text rounded">B</button>
<button onClick={() => applyInlineFormat('weekly-brief-input', editForm.translationBrief, v => setEditForm({ ...editForm, translationBrief: v }), '*')} className="px-2 py-1 text-xs bg-ui-bg text-ui-text rounded italic">I</button>
<button onClick={() => applyLinkFormat('weekly-brief-input', editForm.translationBrief || '', v => setEditForm({ ...editForm, translationBrief: v }))} className="px-2 py-1 text-xs bg-ui-bg text-ui-text rounded">Link</button>
</div>
<textarea
id="weekly-brief-input"
value={editForm.translationBrief}
onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })}
className="w-full p-4 border border-ui-border rounded-lg text-ui-text leading-relaxed text-lg bg-white"
rows={6}
placeholder="Enter translation brief..."
/>
</div>
) : (
<div className="text-ui-text leading-relaxed text-lg font-smiley whitespace-pre-wrap">{renderFormatted(weeklyPracticeWeek.translationBrief || '')}</div>
)}
</div>
) : (
// Show add brief button when no brief exists
(localStorage.getItem('viewMode')||'auto') === 'student' ? false : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin') && (
<div className="bg-ui-panel rounded-xl p-8 mb-8 border border-ui-border border-dashed">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<div className="bg-ui-bg rounded-full p-2">
<BookOpenIcon className="h-5 w-5 text-pink-500" />
</div>
<h3 className="text-ui-text font-semibold text-xl">Translation Brief</h3>
</div>
<button
onClick={startAddingBrief}
className="bg-pink-500 hover:bg-pink-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2"
>
<PlusIcon className="h-4 w-4" />
<span>Add Translation Brief</span>
</button>
</div>
{editingBrief[selectedWeek] && (
<div className="space-y-4">
<div className="flex items-center justify-end space-x-2">
<button onClick={() => applyInlineFormat('weekly-brief-input', editForm.translationBrief, v => setEditForm({ ...editForm, translationBrief: v }), '**')} className="px-2 py-1 text-xs bg-ui-bg text-ui-text rounded">B</button>
<button onClick={() => applyInlineFormat('weekly-brief-input', editForm.translationBrief, v => setEditForm({ ...editForm, translationBrief: v }), '*')} className="px-2 py-1 text-xs bg-ui-bg text-ui-text rounded italic">I</button>
<button onClick={() => applyLinkFormat('weekly-brief-input', editForm.translationBrief || '', v => setEditForm({ ...editForm, translationBrief: v }))} className="px-2 py-1 text-xs bg-ui-bg text-ui-text rounded">Link</button>
</div>
<textarea
value={editForm.translationBrief}
onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })}
className="w-full p-4 border border-ui-border rounded-lg text-ui-text leading-relaxed text-lg bg-white"
rows={6}
placeholder="Enter translation brief..."
/>
<div className="flex items-center space-x-2">
<button
onClick={saveBrief}
disabled={saving}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition-colors duration-200"
>
{saving ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
) : (
'Save Brief'
)}
</button>
<button
onClick={cancelEditing}
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors duration-200"
>
Cancel
</button>
</div>
</div>
)}
</div>
)
)}
{/* Week Files */}
{(weekFiles.source.length > 0 || weekFiles.translation.length > 0 || (((localStorage.getItem('viewMode')||'auto') === 'student') ? false : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin'))) && (
<div className="bg-white rounded-xl shadow p-6 mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Week {selectedWeek} Files</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-start content-start">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between mb-2 min-h-[40px]">
<span className="font-medium text-gray-800">Source Files</span>
{((JSON.parse(localStorage.getItem('user') || '{}').role || 'visitor') === 'admin') && (
<label className="inline-flex items-center px-3 py-1.5 text-sm bg-pink-500 text-white rounded cursor-pointer hover:bg-pink-600">
<input
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files && e.target.files[0];
if (f) uploadSourceFile(f);
}}
/>
{uploadingSource ? 'Uploading…' : 'Upload'}
</label>
)}
</div>
<ul className="text-sm text-gray-700 space-y-1 max-h-96 overflow-auto divide-y">
{weekFiles.source.length === 0 && (
<li className="text-gray-500">No source files yet.</li>
)}
{weekFiles.source.map((f: any) => (
<li key={f._id} className="flex items-center justify-between space-x-3 py-1">
<div className="min-w-0 truncate font-medium" title={f.fileName}>{f.fileName}</div>
<div className="flex items-center space-x-3 flex-shrink-0">
<button className="text-pink-600 hover:underline" onClick={() => downloadWeekFile(f._id, f.fileName)}>Download</button>
{((localStorage.getItem('viewMode')||'auto') === 'student') ? false : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin') && (
<button
className="text-red-600 hover:underline"
onClick={async () => {
if (!window.confirm('Delete this file?')) return;
await api.delete(`/api/weekly-practice-files/${f._id}`);
await fetchWeekFiles();
}}
>
Delete
</button>
)}
</div>
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between mb-2 min-h-[40px]">
<span className="font-medium text-gray-800">Translation Files</span>
<div className="flex items-center space-x-3">
{weekFiles.translation.length > 10 && (
<button
className="text-xs text-gray-600 hover:text-gray-800 underline"
onClick={() => setShowAllTranslations(v => !v)}
>
{showAllTranslations ? 'Show first 10' : `Show all (${weekFiles.translation.length})`}
</button>
)}
<label className="inline-flex items-center px-3 py-1.5 text-sm bg-orange-600 text-white rounded cursor-pointer hover:bg-orange-700">
<input
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files && e.target.files[0];
if (f) uploadTranslationFile(f);
}}
/>
{uploadingTranslation ? 'Uploading…' : 'Upload'}
</label>
</div>
</div>
<ul className="text-sm text-gray-700 space-y-1 max-h-96 overflow-auto divide-y">
{weekFiles.translation.length === 0 && (
<li className="text-gray-500">Please upload your translated file here.</li>
)}
{(showAllTranslations ? weekFiles.translation : weekFiles.translation.slice(0, 10)).map((f: any) => (
<li key={f._id} className="flex items-center justify-between space-x-3 py-1">
<div className="min-w-0 truncate font-medium" title={f.fileName}>{f.fileName}</div>
<div className="flex items-center space-x-3 flex-shrink-0">
<button className="text-pink-600 hover:underline" onClick={() => downloadWeekFile(f._id, f.fileName)}>Download</button>
{((localStorage.getItem('viewMode')||'auto') === 'student') ? false : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin') && (
<button
className="text-red-600 hover:underline"
onClick={async () => {
if (!window.confirm('Delete this file?')) return;
await api.delete(`/api/weekly-practice-files/${f._id}`);
await fetchWeekFiles();
}}
>
Delete
</button>
)}
</div>
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Subtitling UI for Week 2 (admin only) */}
{!isWeekTransitioning && selectedWeek === 2 && (isAdmin || !isWeekHidden) && showSubmissions && (
<div className="mb-8">
<div className="mb-4">
<button onClick={() => setShowSubmissions(false)} className="px-3 py-1.5 text-sm rounded-md border border-gray-300">Hide Subtitling UI</button>
</div>
{((localStorage.getItem('viewMode')||'auto') !== 'student' && (JSON.parse(localStorage.getItem('user')||'{}').role === 'admin') && showSubmissions) && (
<>
<div className="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
<div className="mb-4">
<h3 className="text-xl font-bold text-gray-900 mb-2">{videoInfo.title}</h3>
</div>
{/* Video Player */}
<div className="bg-black rounded-lg aspect-video overflow-hidden relative">
<video
className="w-full h-full"
controls
preload="metadata"
id="nike-video"
onError={(e) => console.error('Video loading error:', e)}
onLoadStart={() => console.log('Video loading started')}
onCanPlay={() => console.log('Video can play')}
>
<source src="https://huggingface.co/spaces/linguabot/transcreation-frontend/resolve/main/public/videos/nike-winning-isnt-for-everyone.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
{/* On-screen subtitles */}
{currentDisplayedSubtitle && (
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 z-10">
<div className="bg-black bg-opacity-80 text-white px-6 py-3 rounded-lg text-center max-w-lg">
<p className={`text-base font-medium leading-relaxed tracking-wide ${currentDisplayedSubtitle.length <= 42 ? 'whitespace-nowrap' : 'whitespace-pre-line'}`} style={{ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }}>
{formatSubtitleForDisplay(currentDisplayedSubtitle)}
</p>
</div>
</div>
)}
</div>
</div>
{/* Right Panel - Translation Workspace */}
<div className="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
<h3 className="text-xl font-bold text-gray-900 mb-4">Translation Workspace</h3>
{/* Segment Navigation Grid */}
{loadingSubtitles ? (
<div className="grid grid-cols-9 gap-1 mb-6">
{Array.from({ length: 26 }, (_, i) => (
<div key={i} className="px-1 py-1 rounded text-xs w-full bg-gray-200 animate-pulse">
{i + 1}
</div>
))}
</div>
) : (
<div className="grid grid-cols-9 gap-1 mb-6">
{subtitleSegments.map((segment) => (
<button
key={segment.id}
onClick={() => handleSegmentClick(segment.id)}
className={`px-1 py-1 rounded text-xs w-full ${getSegmentButtonClass(segment.id)}`}
>
{segment.id}
</button>
))}
</div>
)}
{/* Admin Time Code Editor */}
{editingSegment && (((localStorage.getItem('viewMode')||'auto') === 'student') ? false : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin')) && (
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4 mb-4">
<h4 className="text-sm font-medium text-indigo-800 mb-2">Edit Time Codes for Segment {editingSegment}</h4>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-indigo-700 mb-1">Start Time</label>
<input
type="text"
id="startTimeInput"
defaultValue={subtitleSegments[editingSegment - 1]?.startTime}
className="w-full px-2 py-1 text-xs border border-indigo-300 rounded"
placeholder="00:00:00,000"
/>
</div>
<div>
<label className="block text-xs text-indigo-700 mb-1">End Time</label>
<input
type="text"
id="endTimeInput"
defaultValue={subtitleSegments[editingSegment - 1]?.endTime}
className="w-full px-2 py-1 text-xs border border-indigo-300 rounded"
placeholder="00:00:00,000"
/>
</div>
</div>
<div className="flex space-x-2 mt-2">
<button
onClick={() => setEditingSegment(null)}
className="px-2 py-1 text-xs bg-gray-500 text-white rounded"
>
Cancel
</button>
<button
onClick={() => {
const startInput = document.getElementById('startTimeInput') as HTMLInputElement;
const endInput = document.getElementById('endTimeInput') as HTMLInputElement;
if (startInput && endInput && startInput.value && endInput.value) {
handleSaveTimeCode(editingSegment, startInput.value, endInput.value);
}
}}
className="px-2 py-1 text-xs bg-indigo-600 text-white rounded"
>
Save
</button>
</div>
</div>
)}
{/* Current Segment Details */}
<div className="bg-gray-50 rounded-lg p-4 mb-4 border border-gray-200">
<div className="flex items-center justify-between">
<p className="text-gray-800">
Segment {currentSegment}: {subtitleSegments[currentSegment - 1]?.startTime} – {subtitleSegments[currentSegment - 1]?.endTime} ({subtitleSegments[currentSegment - 1]?.duration})
</p>
{((localStorage.getItem('viewMode')||'auto') === 'student') ? false : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin') && (
<button
onClick={() => handleEditTimeCode(currentSegment)}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-2 py-1 rounded text-xs flex items-center space-x-1"
title="Edit time codes"
>
<PencilIcon className="w-3 h-3" />
<span>Edit</span>
</button>
)}
</div>
</div>
{/* Source Text */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">Source Text</label>
<textarea
value={subtitleText}
readOnly
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-800"
rows={2}
/>
</div>
{/* Target Text */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">Target Text</label>
<textarea
value={targetText}
onChange={(e) => handleTargetTextChange(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"
rows={2}
placeholder="Enter your translation..."
/>
<div className="flex justify-between items-center mt-2">
<span className="text-xs text-gray-600">Recommended: 2 lines max, 16 chars/line (Netflix standard)</span>
<span className="text-xs text-gray-600">{characterCount} characters</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex space-x-3 mb-4">
<button
onClick={handleSaveTranslation}
className={`px-4 py-2 rounded-lg ${
saveStatus === 'Saved!' ? 'bg-green-600 hover:bg-green-700' :
saveStatus === 'Saved Locally' ? 'bg-yellow-600 hover:bg-yellow-700' :
saveStatus === 'Error' ? 'bg-red-600 hover:bg-red-700' :
'bg-indigo-600 hover:bg-indigo-700'
} text-white`}
>
{saveStatus}
</button>
<button
onClick={handlePreviewTranslation}
className={`px-4 py-2 rounded-lg flex items-center space-x-2 ${
showTranslatedSubtitles
? 'bg-green-500 hover:bg-green-600 text-white'
: 'bg-gray-500 hover:bg-gray-600 text-white'
}`}
>
<span>{showTranslatedSubtitles ? 'Show Original' : 'Show Translation'}</span>
</button>
</div>
{/* Translation Progress */}
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-700">Translation Progress</span>
<span className="text-sm text-gray-600">{Object.keys(subtitleTranslations).length} of {subtitleSegments.length} segments completed</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${(Object.keys(subtitleTranslations).length / subtitleSegments.length) * 100}%` }}
></div>
</div>
</div>
</div>
</>
)}
</div>
)}
<div className="space-y-6">
{/* Show Subtitling UI Button for Week 2 Admin */}
{selectedWeek === 2 && (() => {
const user = JSON.parse(localStorage.getItem('user') || '{}');
const viewMode = (localStorage.getItem('viewMode') || 'auto');
return viewMode === 'student' ? false : user.role === 'admin';
})() && (
<div className="mb-4">
<button onClick={() => setShowSubmissions(v=>!v)} className="px-3 py-1.5 text-sm rounded-md border border-gray-300">{showSubmissions ? 'Hide Subtitling UI' : 'Show Subtitling UI (Admin)'}</button>
</div>
)}
{/* Add Practice Button for Admin */}
{(() => {
const user = JSON.parse(localStorage.getItem('user') || '{}');
const viewMode = (localStorage.getItem('viewMode') || 'auto');
return viewMode === 'student' ? false : user.role === 'admin';
})() && (
<div className="mb-6">
{addingPractice ? (
<div className="bg-white rounded-xl shadow-lg border border-gray-100 p-6 w-full">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<div className="bg-orange-100 rounded-full p-2">
<PlusIcon className="h-5 w-5 text-orange-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900">Add New Weekly Practice</h3>
</div>
<div className="flex items-center space-x-2">
<button
onClick={saveNewPractice}
disabled={saving}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2"
>
{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 Practice</span>
</>
)}
</button>
<button
onClick={cancelAddingPractice}
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"
>
<XMarkIcon className="h-4 w-4" />
<span>Cancel</span>
</button>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Practice Content <span className="text-gray-500">(optional if image is provided)</span>
</label>
<div className="flex items-center justify-end space-x-2 mb-2">
<button onClick={() => applyInlineFormat('weekly-newpractice-input', editForm.content, v => setEditForm({ ...editForm, content: v }), '**')} className="px-2 py-1 text-xs bg-orange-100 text-orange-800 rounded">B</button>
<button onClick={() => applyInlineFormat('weekly-newpractice-input', editForm.content, v => setEditForm({ ...editForm, content: v }), '*')} className="px-2 py-1 text-xs bg-orange-100 text-orange-800 rounded italic">I</button>
<button onClick={() => applyLinkFormat('weekly-newpractice-input', editForm.content || '', v => setEditForm({ ...editForm, content: v }))} className="px-2 py-1 text-xs bg-orange-100 text-orange-800 rounded">Link</button>
</div>
<textarea
id="weekly-newpractice-input"
value={editForm.content}
onChange={(e) => setEditForm({ ...editForm, content: e.target.value })}
className="w-full p-4 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
rows={4}
placeholder="Enter weekly practice content (or upload an image below)..."
/>
</div>
{selectedWeek >= 3 && (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Image URL <span className="text-gray-500">(optional if content is provided)</span>
</label>
<input
type="text"
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-orange-500"
placeholder="Enter image URL..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">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-orange-500"
placeholder="Enter alt text for accessibility..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Upload Local Image</label>
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const imageUrl = await handleFileUpload(file);
setEditForm({ ...editForm, imageUrl });
} catch (error) {
console.error('Error uploading file:', error);
}
}
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
{uploading && (
<div className="mt-2 text-sm text-orange-600">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-600 inline-block mr-2"></div>
Uploading...
</div>
)}
</div>
</div>
)}
</div>
</div>
) : addingImage ? (
<div className="bg-white rounded-xl shadow-lg border border-gray-100 p-6 w-full">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<div className="bg-blue-100 rounded-full p-2">
<PlusIcon className="h-5 w-5 text-blue-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900">Add New Image</h3>
</div>
<div className="flex items-center space-x-2">
<button
onClick={saveNewImage}
disabled={saving}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2"
>
{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"
>
<XMarkIcon className="h-4 w-4" />
<span>Cancel</span>
</button>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Image URL</label>
<input
type="text"
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"
placeholder="Enter image URL..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Image Alt Text</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"
placeholder="Enter alt text for accessibility..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Upload Local Image</label>
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const imageUrl = await handleFileUpload(file);
setImageForm({ ...imageForm, imageUrl });
} catch (error) {
console.error('Error uploading file:', error);
}
}
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{uploading && (
<div className="mt-2 text-sm text-blue-600">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 inline-block mr-2"></div>
Uploading...
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">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"
>
<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-1">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"
>
<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>
) : (
<div className="bg-gradient-to-r from-orange-50 to-amber-50 rounded-xl p-6 border border-orange-200 border-dashed">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="bg-orange-100 rounded-full p-2">
<PlusIcon className="h-5 w-5 text-orange-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-orange-900">Add New Weekly Practice</h3>
<p className="text-orange-700 text-sm">Create a new practice example for Week {selectedWeek}</p>
</div>
</div>
<div className="flex space-x-3">
<button
onClick={startAddingPractice}
className="bg-orange-600 hover:bg-orange-700 text-white px-6 py-3 rounded-lg transition-colors duration-200 flex items-center space-x-2 shadow-lg hover:shadow-xl"
>
<PlusIcon className="h-5 w-5" />
<span className="font-medium">Add Practice</span>
</button>
{selectedWeek >= 3 && (
<button
onClick={startAddingImage}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg transition-colors duration-200 flex items-center space-x-2 shadow-lg hover:shadow-xl"
>
<PlusIcon className="h-5 w-5" />
<span className="font-medium">Add Image</span>
</button>
)}
</div>
</div>
</div>
)}
</div>
)}
{!isWeekTransitioning && (weeklyPractice.length === 0 && !addingPractice) && (
<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 practice examples available
</h3>
<p className="text-gray-600">
Practice examples for Week {selectedWeek} haven't been set up yet.
</p>
</div>
)}
{!isWeekTransitioning && !(weeklyPractice.length === 0 && !addingPractice) && (
<>
{weeklyPractice.map((practice) => (
<div key={practice._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-pink-100 rounded-full p-2">
<DocumentTextIcon className="h-5 w-5 text-pink-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Source Text #{weeklyPractice.indexOf(practice) + 1}</h3>
</div>
</div>
{((localStorage.getItem('viewMode')||'auto') === 'student') ? null : (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin') && (
<div className="flex items-center space-x-2">
{editingPractice === practice._id ? (
<>
<button
onClick={savePractice}
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(practice)}
className="bg-blue-100 hover:bg-blue-200 text-blue-700 px-3 py-1 rounded-lg transition-colors duration-200"
>
<PencilIcon className="h-4 w-4" />
</button>
<button
onClick={() => deletePractice(practice._id)}
className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
>
<TrashIcon className="h-4 w-4" />
</button>
</>
)}
</div>
)}
</div>
{/* Content - Gradient underlay + glass overlay */}
<div className="relative rounded-xl mb-6 p-0 border border-pink-200/60">
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-pink-200/45 via-rose-200/40 to-pink-300/45" />
<div className="relative rounded-xl bg-white/10 backdrop-blur-md ring-1 ring-inset ring-white/30 shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)] p-6">
<div className="pointer-events-none absolute inset-0 rounded-xl opacity-50 [background:linear-gradient(to_bottom,rgba(255,255,255,0.3),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.28),rgba(255,255,255,0)_28%)]" />
<div className="pointer-events-none absolute inset-0 rounded-xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-45" />
<div className="pointer-events-none absolute inset-0 rounded-xl" style={{ background: 'radial-gradient(120% 120% at 50% 55%, rgba(0,0,0,0.03), rgba(0,0,0,0) 60%)' }} />
<div className="pointer-events-none absolute inset-0 rounded-xl bg-pink-500/10 mix-blend-overlay opacity-25" />
{editingPractice === practice._id ? (
<div className="space-y-4">
<textarea
value={editForm.content}
onChange={(e) => setEditForm({...editForm, content: e.target.value})}
className="w-full px-4 py-3 border border-orange-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white"
rows={5}
placeholder="Enter source text..."
/>
{selectedWeek >= 2 && (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-orange-700 mb-1">Image URL</label>
<input
type="text"
value={editForm.imageUrl}
onChange={(e) => setEditForm({...editForm, imageUrl: e.target.value})}
className="w-full px-3 py-2 border border-orange-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500"
placeholder="Enter image URL..."
/>
</div>
<div>
<label className="block text-sm font-medium text-orange-700 mb-1">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-orange-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500"
placeholder="Enter alt text for accessibility..."
/>
</div>
<div>
<label className="block text-sm font-medium text-orange-700 mb-1">Upload Local Image</label>
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const imageUrl = await handleFileUpload(file);
setEditForm({ ...editForm, imageUrl });
} catch (error) {
console.error('Error uploading file:', error);
}
}
}}
className="w-full px-3 py-2 border border-orange-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
{uploading && (
<div className="mt-2 text-sm text-orange-600">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-600 inline-block mr-2"></div>
Uploading...
</div>
)}
</div>
</div>
)}
</div>
) : (
<div>
{practice.imageUrl ? (
selectedWeek >= 4 && practice.imageAlignment === 'portrait-split' ? (
// 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={practice.imageUrl} alt={practice.imageAlt || 'Uploaded image'} className="w-full h-auto" style={{ maxHeight: '520px', objectFit: 'contain' }} />
</div>
</div>
<div className="w-full">
<div className="mb-4 text-ui-text leading-relaxed text-lg font-source-text whitespace-pre-wrap">{practice.content}</div>
{localStorage.getItem('token') && (
<div className="relative rounded-xl bg-white/10 backdrop-blur-md ring-1 ring-inset ring-white/30 shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)] p-4">
<div className="pointer-events-none absolute inset-0 rounded-xl opacity-50 [background:linear-gradient(to_bottom,rgba(255,255,255,0.3),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.28),rgba(255,255,255,0)_28%)]" />
<div className="pointer-events-none absolute inset-0 rounded-xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-45" />
<h5 className="relative z-10 text-ui-text font-semibold mb-2">Your Translation</h5>
<div className="relative z-10 flex items-center justify-end space-x-2 mb-2">
<button onClick={() => applyInlineFormat(`weekly-translation-${practice._id}`, translationText[practice._id] || '', v => setTranslationText({ ...translationText, [practice._id]: v }), '**')} className="px-2 py-1 text-xs bg-white/40 text-ui-text rounded">B</button>
<button onClick={() => applyInlineFormat(`weekly-translation-${practice._id}`, translationText[practice._id] || '', v => setTranslationText({ ...translationText, [practice._id]: v }), '*')} className="px-2 py-1 text-xs bg-white/40 text-ui-text rounded italic">I</button>
<button onClick={() => applyLinkFormat(`weekly-translation-${practice._id}`, translationText[practice._id] || '', v => setTranslationText({ ...translationText, [practice._id]: v }))} className="px-2 py-1 text-xs bg-white/40 text-ui-text rounded">Link</button>
</div>
<textarea id={`weekly-translation-${practice._id}`} value={translationText[practice._id] || ''} onChange={(e) => setTranslationText({ ...translationText, [practice._id]: e.target.value })} className="relative z-10 w-full px-4 py-3 border border-ui-border rounded-lg focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-pink-500 bg-white" rows={4} placeholder="Enter your translation here..." />
<div className="relative z-10 flex justify-end mt-2">
<button onClick={() => handleSubmitTranslation(practice._id)} disabled={submitting[practice._id]} className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-pink-600/70 disabled:bg-gray-400 active:translate-y-0.5 active:shadow-[inset_0_0.5px_0_rgba(255,255,255,0.6),inset_0_-1px_0_rgba(0,0,0,0.12)] transition-all duration-200">
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
<span className="relative z-10">{submitting[practice._id] ? 'Submitting...' : 'Submit Translation'}</span>
</button>
</div>
</div>
)}
</div>
</div>
) : practice.content === 'Image-based practice' ? (
// Image-only layout
<div className={`flex flex-col md:flex-row gap-6 items-start ${practice.imageAlignment === 'left' ? 'md:flex-row' : practice.imageAlignment === 'right' ? 'md:flex-row-reverse' : 'md:flex-col'}`}>
<div className={`${practice.imageAlignment === 'left' || practice.imageAlignment === 'right' ? 'w-full md:w-1/2' : 'w-full'} flex ${practice.imageAlignment === 'left' ? 'justify-start' : practice.imageAlignment === 'right' ? 'justify-end' : 'justify-center'}`}>
<div className="inline-block rounded-lg shadow-md overflow-hidden">
<img src={practice.imageUrl} alt={practice.imageAlt || 'Uploaded image'} className="h-auto" style={{ height: `${practice.imageSize || 200}px`, width: 'auto', maxWidth: '100%', objectFit: 'contain', imageRendering: 'auto' }} onError={(e) => { console.error('Error loading image:', e); (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
{practice.imageAlt && (<div className="text-xs text-gray-500 mt-2 text-center">Alt: {practice.imageAlt}</div>)}
</div>
</div>
</div>
) : (
// Regular layout
<div className="flex flex-col md:flex-row gap-6 items-start">
<div className="w-full md:w-1/2 flex justify-center">
{practice.imageUrl.startsWith('data:') ? (
<div className="inline-block rounded-lg shadow-md overflow-hidden">
<img src={practice.imageUrl} alt={practice.imageAlt || 'Uploaded image'} className="w-full h-auto" style={{ height: '200px', width: 'auto', objectFit: 'contain' }} onError={(e) => { console.error('Error loading image:', 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">{practice.imageUrl}</div>
</div>
)}
</div>
<div className="w-full md:w-1/2">
<p className="text-ui-text leading-relaxed text-lg font-source-text whitespace-pre-wrap">{practice.content}</p>
</div>
</div>
)
) : (
// Text only when no image
<p className="text-ui-text leading-relaxed text-lg font-source-text whitespace-pre-wrap">{practice.content}</p>
)}
</div>
)}
</div>
</div>
</div>
{/* All Submissions for this Practice */}
{userSubmissions[practice._id] && userSubmissions[practice._id].length > 0 && (
<div className="bg-gradient-to-r from-white to-orange-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-amber-100 rounded-full p-1">
<CheckCircleIcon className="h-4 w-4 text-amber-600" />
</div>
<h4 className="text-stone-900 font-semibold text-lg">All Submissions ({userSubmissions[practice._id].length})</h4>
</div>
<button
onClick={() => toggleExpanded(practice._id)}
className="flex items-center space-x-1 text-amber-700 hover:text-amber-800 text-sm font-medium"
>
<span>{expandedSections[practice._id] ? 'Collapse' : 'Expand'}</span>
<svg
className={`w-4 h-4 transition-transform duration-200 ${expandedSections[practice._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[practice._id]
? 'max-h-none overflow-visible'
: 'max-h-0 overflow-hidden'
}`}>
{userSubmissions[practice._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">By:</span>
<span className="bg-amber-100 px-1.5 py-0.5 rounded-full text-amber-800 text-xs">
{submission.userId?.username || 'Unknown'}
</span>
</div>
<div className="flex items-center space-x-1">
<span className="font-medium">Votes:</span>
<span className="bg-amber-100 px-1.5 py-0.5 rounded-full text-xs">
{(submission.voteCounts?.['1'] || 0) + (submission.voteCounts?.['2'] || 0) + (submission.voteCounts?.['3'] || 0)}
</span>
</div>
{(submission.isOwner || (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin')) && (
<button
onClick={() => handleEditSubmission(submission._id, submission.transcreation)}
className="text-purple-600 hover:text-purple-800 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 ml-2"
>
Delete
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Translation Input (always show for logged-in users on all weeks; keep hidden for image-only and portrait-split variants) */}
{localStorage.getItem('token') && practice.content !== 'Image-based practice' && !(selectedWeek >= 4 && practice.imageAlignment === 'portrait-split') && (
<div className="relative rounded-xl p-6 border border-ui-border bg-white/10 backdrop-blur-md ring-1 ring-inset ring-white/30 shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)]">
<div className="pointer-events-none absolute inset-0 rounded-xl opacity-50 [background:linear-gradient(to_bottom,rgba(255,255,255,0.3),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.28),rgba(255,255,255,0)_28%)]" />
<div className="pointer-events-none absolute inset-0 rounded-xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-45" />
<div className="relative z-10 flex items-center space-x-2 mb-4">
<div className="relative p-1 rounded-full bg-white/20 ring-1 ring-inset ring-white/30">
<DocumentTextIcon className="h-4 w-4 text-ui-text" />
</div>
<h4 className="text-ui-text font-semibold text-lg">Your Translation</h4>
</div>
<div className="mb-4">
<div className="flex items-center justify-end space-x-2 mb-2">
<button onClick={() => applyInlineFormat(`weekly-translation-${practice._id}`, translationText[practice._id] || '', v => setTranslationText({ ...translationText, [practice._id]: v }), '**')} className="px-2 py-1 text-xs bg-white/40 text-ui-text rounded">B</button>
<button onClick={() => applyInlineFormat(`weekly-translation-${practice._id}`, translationText[practice._id] || '', v => setTranslationText({ ...translationText, [practice._id]: v }), '*')} className="px-2 py-1 text-xs bg-white/40 text-ui-text rounded italic">I</button>
<button onClick={() => applyLinkFormat(`weekly-translation-${practice._id}`, translationText[practice._id] || '', v => setTranslationText({ ...translationText, [practice._id]: v }))} className="px-2 py-1 text-xs bg-white/40 text-ui-text rounded">Link</button>
</div>
<textarea id={`weekly-translation-${practice._id}`}
value={translationText[practice._id] || ''}
onChange={(e) => setTranslationText({ ...translationText, [practice._id]: e.target.value })}
className="w-full px-4 py-3 border border-ui-border rounded-lg focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-pink-500 bg-white"
rows={4}
placeholder="Enter your translation here..."
/>
</div>
<div className="mb-4">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={anonymousSubmissions[practice._id] || false}
onChange={(e) => setAnonymousSubmissions({
...anonymousSubmissions,
[practice._id]: e.target.checked
})}
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<span className="text-sm text-stone-700">Submit anonymously</span>
</label>
</div>
<button
onClick={() => handleSubmitTranslation(practice._id)}
disabled={submitting[practice._id]}
className="relative overflow-hidden inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-2xl text-white ring-1 ring-inset ring-white/50 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150 bg-pink-600/70 disabled:bg-gray-400 active:translate-y-0.5 active:shadow-[inset_0_0.5px_0_rgba(255,255,255,0.6),inset_0_-1px_0_rgba(0,0,0,0.12)] transition-all duration-200"
>
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" />
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" />
{submitting[practice._id] ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Submitting...
</>
) : (
<>
<span className="relative z-10">Submit Translation</span>
</>
)}
</button>
</div>
)}
{/* Show login message for visitors */}
{!localStorage.getItem('token') && (
<div className="bg-gradient-to-r from-gray-50 to-blue-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 weekly practice.
</p>
<button
onClick={() => window.location.href = '/login'}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center transition-all duration-200 transform hover:scale-105"
>
Go to Login
<ArrowRightIcon className="h-4 w-4 ml-2" />
</button>
</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>
</div>
);
};
export default WeeklyPractice;