Spaces:
Sleeping
Sleeping
| 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, | |
| ArrowRightIcon as ArrowRightIconSolid | |
| } from '@heroicons/react/24/outline'; | |
| interface WeeklyPractice { | |
| _id: string; | |
| content: string; | |
| weekNumber: number; | |
| translationBrief?: string; | |
| } | |
| 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; | |
| } | |
| // New interfaces for subtitling | |
| 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; | |
| } | |
| const WeeklyPractice: React.FC = () => { | |
| const [selectedWeek, setSelectedWeek] = useState<number>(() => { | |
| const savedWeek = localStorage.getItem('selectedWeeklyPracticeWeek'); | |
| return savedWeek ? parseInt(savedWeek) : 1; | |
| }); | |
| const [weeklyPractice, setWeeklyPractice] = useState<WeeklyPractice[]>([]); | |
| const [weeklyPracticeWeek, setWeeklyPracticeWeek] = useState<WeeklyPracticeWeek | null>(null); | |
| 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}>({}); | |
| const [editingPractice, setEditingPractice] = useState<string | null>(null); | |
| const [editingBrief, setEditingBrief] = useState<{[key: number]: boolean}>({}); | |
| const [addingPractice, setAddingPractice] = useState<boolean>(false); | |
| const [editForm, setEditForm] = useState<{ | |
| content: string; | |
| translationBrief: string; | |
| }>({ | |
| content: '', | |
| translationBrief: '' | |
| }); | |
| const [saving, setSaving] = useState(false); | |
| const navigate = useNavigate(); | |
| // HTML5 video with direct file access for precise control (like Amara) | |
| const [videoRef, setVideoRef] = useState<HTMLVideoElement | null>(null); | |
| const [currentSegment, setCurrentSegment] = useState<number>(() => { | |
| const savedSegment = localStorage.getItem('currentSegment'); | |
| return savedSegment ? parseInt(savedSegment) : 3; | |
| }); | |
| const [isPlaying, setIsPlaying] = useState<boolean>(false); | |
| const [currentTime, setCurrentTime] = useState<string>('00:15'); | |
| const [totalTime, setTotalTime] = useState<string>('01:30'); | |
| const [subtitleText, setSubtitleText] = useState<string>('I\'m single-minded, I\'m deceptive, I\'m obsessive...'); | |
| const [targetText, setTargetText] = useState<string>('Soy determinado, soy astuto, soy obsesivo...'); | |
| const [characterCount, setCharacterCount] = useState<number>(47); | |
| const [translations, setTranslations] = useState<{ [key: number]: string }>({}); | |
| const [previewText, setPreviewText] = useState<string>(''); | |
| const [isPreviewMode, setIsPreviewMode] = useState<boolean>(false); | |
| const [savedSegments, setSavedSegments] = useState<Set<number>>(new Set()); | |
| const weeks = [1, 2, 3, 4, 5, 6]; | |
| const videoInfo: VideoInfo = { | |
| title: 'Nike \'Winning Isn\'t for Everyone\'', | |
| duration: '1:26', | |
| totalSegments: 26, | |
| currentSegment: 3 | |
| }; | |
| // DEFINITIVE CORRECT TIMECODES - Cross-platform compatible | |
| const defaultSegments: SubtitleSegment[] = [ | |
| { id: 1, startTime: '00:00:00,640', endTime: '00:00:02,600', duration: '1s 960ms', sourceText: 'Am I a bad person?' }, | |
| { id: 2, startTime: '00:00:06,320', endTime: '00:00:08,000', duration: '1s 679.9999999999998ms', sourceText: 'Tell me. Am I?' }, | |
| { id: 3, startTime: '00:00:08,480', endTime: '00:00:10,200', duration: '1s 719.9999999999989ms', sourceText: 'I\'m single minded.', isCurrent: true }, | |
| { id: 4, startTime: '00:00:10,570', endTime: '00:00:12,100', duration: '1s 529.9999999999993ms', sourceText: 'I\'m deceptive.' }, | |
| { id: 5, startTime: '00:00:12,200', endTime: '00:00:13,690', duration: '1s 490.0000000000002ms', sourceText: 'I\'m obsessive.' }, | |
| { id: 6, startTime: '00:00:13,900', endTime: '00:00:15,210', duration: '1s 310.00000000000045ms', sourceText: 'I\'m selfish.' }, | |
| { id: 7, startTime: '00:00:15,220', endTime: '00:00:17,800', duration: '2s 580ms', sourceText: 'Does that make me a bad person?' }, | |
| { id: 8, startTime: '00:00:18,010', endTime: '00:00:19,990', duration: '1s 979.9999999999968ms', sourceText: 'Am I a bad person?' }, | |
| { id: 9, startTime: '00:00:20,870', endTime: '00:00:21,700', duration: '0s 829.9999999999983ms', sourceText: 'Am I?' }, | |
| { id: 10, startTime: '00:00:23,120', endTime: '00:00:24,700', duration: '1s 579.9999999999982ms', sourceText: 'I have no empathy.' }, | |
| { id: 11, startTime: '00:00:25,540', endTime: '00:00:27,650', duration: '2s 109.99999999999955ms', sourceText: 'I don\'t respect you.' }, | |
| { id: 12, startTime: '00:00:28,550', endTime: '00:00:30,500', duration: '1s 949.9999999999993ms', sourceText: 'I\'m never satisfied.' }, | |
| { id: 13, startTime: '00:00:30,500', endTime: '00:00:33,500', duration: '3s 0ms', sourceText: 'I have an obsession with power.' }, | |
| { id: 14, startTime: '00:00:38,000', endTime: '00:00:39,300', duration: '1s 299.99999999999727ms', sourceText: 'I\'m irrational.' }, | |
| { id: 15, startTime: '00:00:39,900', endTime: '00:00:41,700', duration: '1s 800.0000000000043ms', sourceText: 'I have zero remorse.' }, | |
| { id: 16, startTime: '00:00:42,000', endTime: '00:00:44,200', duration: '2s 200.00000000000273ms', sourceText: 'I have no sense of compassion.' }, | |
| { id: 17, startTime: '00:00:44,480', endTime: '00:00:46,950', duration: '2s 470.0000000000059ms', sourceText: 'I\'m delusional. I\'m maniacal.' }, | |
| { id: 18, startTime: '00:00:46,960', endTime: '00:00:49,319', duration: '2s 359.0000000000018ms', sourceText: 'You think I\'m a bad person?' }, | |
| { id: 19, startTime: '00:00:49,320', endTime: '00:00:52,900', duration: '3s 579.9999999999982ms', sourceText: 'Tell me. Tell me. Tell me.\nTell me. Am I?' }, | |
| { id: 20, startTime: '00:00:52,990', endTime: '00:00:55,299', duration: '2s 308.99999999999727ms', sourceText: 'I think I\'m better than everyone else.' }, | |
| { id: 21, startTime: '00:00:55,300', endTime: '00:00:57,899', duration: '2s 599.0000000000036ms', sourceText: 'I want to take what\'s yours\nand never give it back.' }, | |
| { id: 22, startTime: '00:00:57,900', endTime: '00:01:01,200', duration: '3s 300.0000000000041ms', sourceText: 'What\'s mine is mine\nand what\'s yours is mine.' }, | |
| { id: 23, startTime: '00:01:06,920', endTime: '00:01:08,830', duration: '1s 909.9999999999966ms', sourceText: 'Am I a bad person?' }, | |
| { id: 24, startTime: '00:01:08,840', endTime: '00:01:11,120', duration: '2s 280.0000000000009ms', sourceText: 'Tell me. Am I?' }, | |
| { id: 25, startTime: '00:01:21,500', endTime: '00:01:24,000', duration: '2s 500ms', sourceText: 'Does that make me a bad person?' }, | |
| { id: 26, startTime: '00:01:25,060', endTime: '00:01:27,100', duration: '2s 39.99999999999204ms', sourceText: 'Tell me. Does it?' }, | |
| ]; | |
| const [subtitleSegments, setSubtitleSegments] = useState<SubtitleSegment[]>(() => { | |
| const saved = localStorage.getItem('subtitleSegments'); | |
| return saved ? JSON.parse(saved) : defaultSegments; | |
| }); | |
| // Load subtitle data from localStorage (since backend endpoints don't exist yet) | |
| const loadSubtitleDataFromDatabase = async () => { | |
| try { | |
| // For now, use localStorage as the backend endpoints don't exist | |
| const savedSegments = localStorage.getItem('subtitleSegments'); | |
| if (savedSegments) { | |
| const segments = JSON.parse(savedSegments); | |
| setSubtitleSegments(segments); | |
| console.log('Subtitle data loaded from localStorage'); | |
| } else { | |
| // Use default segments if nothing saved | |
| setSubtitleSegments(defaultSegments); | |
| localStorage.setItem('subtitleSegments', JSON.stringify(defaultSegments)); | |
| console.log('Using default subtitle segments'); | |
| } | |
| } catch (error) { | |
| console.error('Failed to load subtitle data:', error); | |
| // Fallback to default segments | |
| setSubtitleSegments(defaultSegments); | |
| } | |
| }; | |
| // Load translations from localStorage (since backend doesn't have week2-segment submissions) | |
| const loadTranslationsFromDatabase = async () => { | |
| try { | |
| // For now, load translations from localStorage since the backend doesn't have | |
| // submissions with week2-segment IDs | |
| const savedTranslations = localStorage.getItem('week2-translations'); | |
| if (savedTranslations) { | |
| const translations = JSON.parse(savedTranslations); | |
| setTranslations(translations); | |
| // Update saved segments based on translations | |
| const savedSegmentsSet = new Set(Object.keys(translations).map(Number)); | |
| setSavedSegments(savedSegmentsSet); | |
| console.log('Translations loaded from localStorage:', translations); | |
| } | |
| } catch (error) { | |
| console.error('Failed to load translations:', error); | |
| // Don't throw error, just log it and continue | |
| } | |
| }; | |
| // Fetch user submissions (simplified for Week 2 focus) | |
| const fetchUserSubmissions = useCallback(async (practice: WeeklyPractice[]) => { | |
| try { | |
| const user = localStorage.getItem('user'); | |
| const token = localStorage.getItem('token'); | |
| if (!token || !user) { | |
| return; | |
| } | |
| // For Week 2, we don't need to fetch submissions since we're using localStorage | |
| // This function is kept for compatibility but doesn't do anything for Week 2 | |
| console.log('fetchUserSubmissions called but not needed for Week 2'); | |
| } catch (error) { | |
| console.error('Error fetching user submissions:', error); | |
| } | |
| }, []); | |
| // Fetch weekly practice data | |
| const fetchWeeklyPractice = useCallback(async () => { | |
| try { | |
| const response = await api.get(`/source-texts/weekly-practice/week/${selectedWeek}`); | |
| if (response.data) { | |
| setWeeklyPractice(response.data.practices || []); | |
| setWeeklyPracticeWeek(response.data); | |
| console.log('Weekly practice data loaded:', response.data); | |
| } | |
| } catch (error) { | |
| console.error('Error fetching weekly practice:', error); | |
| // Set empty arrays as fallback | |
| setWeeklyPractice([]); | |
| setWeeklyPracticeWeek(null); | |
| } | |
| }, [selectedWeek]); | |
| // Load data on component mount | |
| useEffect(() => { | |
| const loadData = async () => { | |
| await loadSubtitleDataFromDatabase(); | |
| await loadTranslationsFromDatabase(); | |
| await fetchWeeklyPractice(); | |
| setLoading(false); | |
| }; | |
| loadData(); | |
| }, [fetchWeeklyPractice]); | |
| // Load current segment data after translations are loaded | |
| useEffect(() => { | |
| if (!loading && currentSegment) { | |
| const segment = subtitleSegments.find(s => s.id === currentSegment); | |
| if (segment) { | |
| setSubtitleText(segment.sourceText); | |
| const existingTranslation = translations[currentSegment] || ''; | |
| setTargetText(existingTranslation); | |
| setCharacterCount(existingTranslation.length); | |
| // Update video subtitle overlay based on preview mode | |
| if (isPreviewMode && existingTranslation) { | |
| setPreviewText(existingTranslation); | |
| } else { | |
| setPreviewText(segment.sourceText); | |
| } | |
| } | |
| } | |
| }, [loading, currentSegment, subtitleSegments, translations, isPreviewMode]); | |
| // Handle segment click functionality | |
| const handleSegmentClick = (segmentId: number) => { | |
| setCurrentSegment(segmentId); | |
| localStorage.setItem('currentSegment', segmentId.toString()); | |
| const segment = subtitleSegments.find(s => s.id === segmentId); | |
| if (segment && videoRef) { | |
| const startTime = parseTimeToSeconds(segment.startTime); | |
| const endTime = parseTimeToSeconds(segment.endTime); | |
| console.log('Playing segment:', segmentId, 'from', startTime, 'to', endTime, 'duration:', endTime - startTime); | |
| // Seek to start of segment and play | |
| videoRef.currentTime = startTime; | |
| videoRef.play(); | |
| // Set up a timeout to pause at the end of the segment | |
| const segmentDuration = (endTime - startTime) * 1000; // Convert to milliseconds | |
| console.log('Segment duration in ms:', segmentDuration); | |
| setTimeout(() => { | |
| if (videoRef && !videoRef.paused) { | |
| videoRef.pause(); | |
| console.log('Segment ended, video paused at:', videoRef.currentTime); | |
| } | |
| }, segmentDuration); | |
| setCurrentTime(segment.startTime); | |
| setSubtitleText(segment.sourceText); | |
| // Preserve existing translation or load from saved translations | |
| const existingTranslation = translations[segmentId] || ''; | |
| setTargetText(existingTranslation); | |
| setCharacterCount(existingTranslation.length); | |
| // Update video subtitle overlay based on preview mode | |
| if (isPreviewMode && existingTranslation) { | |
| setPreviewText(existingTranslation); | |
| } else { | |
| setPreviewText(segment.sourceText); | |
| } | |
| } | |
| }; | |
| // Handle save and next functionality | |
| const handleSaveAndNext = async () => { | |
| // Save current translation to state | |
| const updatedTranslations = { | |
| ...translations, | |
| [currentSegment]: targetText | |
| }; | |
| setTranslations(updatedTranslations); | |
| // Save to localStorage for persistence | |
| localStorage.setItem('week2-translations', JSON.stringify(updatedTranslations)); | |
| // Track saved segments for progress | |
| setSavedSegments(prev => { | |
| const newSet = new Set(prev); | |
| newSet.add(currentSegment); | |
| return newSet; | |
| }); | |
| console.log('Translation saved for segment:', currentSegment); | |
| // Move to next segment | |
| if (currentSegment < subtitleSegments.length) { | |
| handleSegmentClick(currentSegment + 1); | |
| } | |
| }; | |
| // Handle preview translation functionality | |
| const handlePreviewTranslation = () => { | |
| // Toggle preview mode | |
| setIsPreviewMode(!isPreviewMode); | |
| if (!isPreviewMode) { | |
| // Enable preview mode - show translation | |
| setPreviewText(translations[currentSegment] || targetText); | |
| } else { | |
| // Disable preview mode - show source text | |
| setPreviewText(''); | |
| } | |
| console.log('Preview mode toggled:', !isPreviewMode); | |
| }; | |
| // Handle target text changes | |
| const handleTargetTextChange = (text: string) => { | |
| setTargetText(text); | |
| setCharacterCount(text.length); | |
| }; | |
| // Get segment status for styling | |
| const getSegmentStatus = (segmentId: number) => { | |
| if (savedSegments.has(segmentId)) return 'completed'; | |
| if (segmentId === currentSegment) return 'current'; | |
| return 'pending'; | |
| }; | |
| const getSegmentButtonClass = (segmentId: number) => { | |
| const status = getSegmentStatus(segmentId); | |
| switch (status) { | |
| case 'completed': | |
| return 'bg-green-500 text-white'; | |
| case 'current': | |
| return 'bg-orange-500 text-white'; | |
| default: | |
| return 'bg-gray-300 text-gray-700'; | |
| } | |
| }; | |
| // Save timecodes to localStorage (since backend endpoints don't exist) | |
| const saveTimecodesToDatabase = async (updatedSegments: SubtitleSegment[]) => { | |
| try { | |
| // For now, just save to localStorage since backend endpoints don't exist | |
| localStorage.setItem('subtitleSegments', JSON.stringify(updatedSegments)); | |
| console.log('Timecodes saved to localStorage'); | |
| } catch (error) { | |
| console.error('Failed to save timecodes:', error); | |
| } | |
| }; | |
| // Save source texts to localStorage (since backend endpoints don't exist) | |
| const saveSourceTextsToDatabase = async () => { | |
| try { | |
| // For now, just save to localStorage since backend endpoints don't exist | |
| const sourceTextsData = { | |
| weekNumber: 2, | |
| sourceTexts: subtitleSegments.map(segment => ({ | |
| segmentId: segment.id, | |
| sourceText: segment.sourceText, | |
| startTime: segment.startTime, | |
| endTime: segment.endTime | |
| })), | |
| createdAt: new Date().toISOString() | |
| }; | |
| localStorage.setItem('sourceTexts', JSON.stringify(sourceTextsData)); | |
| console.log('Source texts saved to localStorage'); | |
| } catch (error) { | |
| console.error('Failed to save source texts:', error); | |
| } | |
| }; | |
| const saveTimecodes = async () => { | |
| if (editingSegment) { | |
| // Validate time format | |
| const timeFormat = /^\d{2}:\d{2}:\d{2},\d{3}$/; | |
| if (!timeFormat.test(editStartTime) || !timeFormat.test(editEndTime)) { | |
| alert('Please use the correct time format: HH:MM:SS,mmm'); | |
| return; | |
| } | |
| // Calculate new duration | |
| const startSeconds = parseTimeToSeconds(editStartTime); | |
| const endSeconds = parseTimeToSeconds(editEndTime); | |
| // Validate start < end | |
| if (startSeconds >= endSeconds) { | |
| alert('Start time must be before end time'); | |
| return; | |
| } | |
| // Check for time clashes with other segments | |
| const hasClash = subtitleSegments.some(segment => { | |
| if (segment.id === editingSegment) return false; | |
| const segmentStart = parseTimeToSeconds(segment.startTime); | |
| const segmentEnd = parseTimeToSeconds(segment.endTime); | |
| // Check if new segment overlaps with existing segment | |
| return (startSeconds < segmentEnd && endSeconds > segmentStart); | |
| }); | |
| if (hasClash) { | |
| alert('Time clash detected! This segment overlaps with another segment. Please adjust the timecodes.'); | |
| return; | |
| } | |
| const durationMs = (endSeconds - startSeconds) * 1000; | |
| const duration = `${Math.floor(durationMs / 1000)}s ${durationMs % 1000}ms`; | |
| // Update the subtitle segment with new timecodes | |
| const updatedSegments = subtitleSegments.map(segment => | |
| segment.id === editingSegment | |
| ? { ...segment, startTime: editStartTime, endTime: editEndTime, duration } | |
| : segment | |
| ); | |
| // Update state with new timecodes | |
| setSubtitleSegments(updatedSegments); | |
| localStorage.setItem('subtitleSegments', JSON.stringify(updatedSegments)); | |
| // Save to MongoDB | |
| await saveTimecodesToDatabase(updatedSegments); | |
| await saveSourceTextsToDatabase(); | |
| console.log('Updated timecodes for segment:', editingSegment, 'to:', editStartTime, '-', editEndTime, 'duration:', duration); | |
| setEditingTimecodes(false); | |
| setEditingSegment(null); | |
| setEditStartTime(''); | |
| setEditEndTime(''); | |
| } | |
| }; | |
| const cancelEditingTimecodes = () => { | |
| setEditingTimecodes(false); | |
| setEditingSegment(null); | |
| setEditStartTime(''); | |
| setEditEndTime(''); | |
| }; | |
| const resetToDefaultTimecodes = () => { | |
| if (window.confirm('Are you sure you want to reset all timecodes to default? This will clear all custom changes.')) { | |
| setSubtitleSegments(defaultSegments); | |
| localStorage.removeItem('subtitleSegments'); | |
| console.log('Reset to default timecodes'); | |
| } | |
| }; | |
| // Update subtitle overlay based on current segment | |
| const updateSubtitleOverlay = (segmentId: number) => { | |
| const segment = subtitleSegments.find(s => s.id === segmentId); | |
| if (segment) { | |
| setSubtitleText(segment.sourceText); | |
| setTargetText(translations[segmentId] || ''); | |
| setCharacterCount(translations[segmentId]?.length || 0); | |
| // Update video subtitle overlay based on preview mode | |
| if (isPreviewMode && translations[segmentId]) { | |
| setPreviewText(translations[segmentId]); | |
| } else { | |
| setPreviewText(''); | |
| } | |
| } | |
| }; | |
| // Auto-subtitle sync: Update subtitle overlay when preview mode changes | |
| useEffect(() => { | |
| const segment = subtitleSegments.find(s => s.id === currentSegment); | |
| if (segment) { | |
| if (isPreviewMode && translations[currentSegment]) { | |
| setPreviewText(translations[currentSegment]); | |
| } else { | |
| setPreviewText(segment.sourceText); | |
| } | |
| } | |
| }, [isPreviewMode, currentSegment, translations, subtitleSegments]); | |
| // Helper function to parse time to seconds | |
| const parseTimeToSeconds = (timeString: string): number => { | |
| const parts = timeString.split(':'); | |
| const hours = parseInt(parts[0]); | |
| const minutes = parseInt(parts[1]); | |
| const seconds = parseFloat(parts[2].replace(',', '.')); | |
| return hours * 3600 + minutes * 60 + seconds; | |
| }; | |
| const formatTime = (seconds: number) => { | |
| const mins = Math.floor(seconds / 60); | |
| const secs = Math.floor(seconds % 60); | |
| return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; | |
| }; | |
| // Refresh submissions when user changes (after login/logout) | |
| useEffect(() => { | |
| const user = localStorage.getItem('user'); | |
| if (user && weeklyPractice.length > 0) { | |
| fetchUserSubmissions(weeklyPractice); | |
| } | |
| }, [weeklyPractice, fetchUserSubmissions]); | |
| // Additional effect to handle user switching | |
| useEffect(() => { | |
| const handleUserChange = () => { | |
| const user = localStorage.getItem('user'); | |
| if (user && weeklyPractice.length > 0) { | |
| fetchUserSubmissions(weeklyPractice); | |
| } | |
| }; | |
| // Listen for storage changes (when user logs in/out) | |
| window.addEventListener('storage', handleUserChange); | |
| // Also check on focus (when user switches tabs/windows) | |
| window.addEventListener('focus', handleUserChange); | |
| // Also check when the page becomes visible | |
| const handleVisibilityChange = () => { | |
| if (!document.hidden) { | |
| handleUserChange(); | |
| } | |
| }; | |
| document.addEventListener('visibilitychange', handleVisibilityChange); | |
| return () => { | |
| window.removeEventListener('storage', handleUserChange); | |
| window.removeEventListener('focus', handleUserChange); | |
| document.removeEventListener('visibilitychange', handleVisibilityChange); | |
| }; | |
| }, [weeklyPractice, fetchUserSubmissions]); | |
| const handleRefreshSubmissions = async () => { | |
| if (weeklyPractice.length > 0) { | |
| await fetchUserSubmissions(weeklyPractice); | |
| } | |
| }; | |
| const handleSubmitTranslation = async (practiceId: string) => { | |
| if (!translationText[practiceId]?.trim()) { | |
| alert('Please provide a translation'); | |
| return; | |
| } | |
| try { | |
| setSubmitting({ ...submitting, [practiceId]: true }); | |
| const user = JSON.parse(localStorage.getItem('user') || '{}'); | |
| const response = await api.post('/submissions', { | |
| sourceTextId: practiceId, | |
| transcreation: translationText[practiceId], | |
| culturalAdaptations: [], | |
| isAnonymous: anonymousSubmissions[practiceId] || false, | |
| username: user.name || 'Unknown' | |
| }); | |
| if (response.status >= 200 && response.status < 300) { | |
| setTranslationText({ ...translationText, [practiceId]: '' }); | |
| await fetchUserSubmissions(weeklyPractice); | |
| } else { | |
| console.error('Failed to submit translation:', 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(''); | |
| // Timecode editing for admin users | |
| const [editingTimecodes, setEditingTimecodes] = useState<boolean>(false); | |
| const [editingSegment, setEditingSegment] = useState<number | null>(null); | |
| const [editStartTime, setEditStartTime] = useState<string>(''); | |
| const [editEndTime, setEditEndTime] = useState<string>(''); | |
| // Start editing timecodes for admin users | |
| const startEditingTimecodes = (segmentId: number) => { | |
| const segment = subtitleSegments.find(s => s.id === segmentId); | |
| if (segment) { | |
| setEditingSegment(segmentId); | |
| setEditStartTime(segment.startTime); | |
| setEditEndTime(segment.endTime); | |
| setEditingTimecodes(true); | |
| } | |
| }; | |
| const handleEditSubmission = async (submissionId: string, currentText: string) => { | |
| setEditingSubmission({ id: submissionId, text: currentText }); | |
| setEditSubmissionText(currentText); | |
| }; | |
| const saveEditedSubmission = async () => { | |
| if (!editingSubmission || !editSubmissionText.trim()) return; | |
| try { | |
| const response = await api.put(`/submissions/${editingSubmission.id}`, { | |
| transcreation: editSubmissionText | |
| }); | |
| if (response.status >= 200 && response.status < 300) { | |
| setEditingSubmission(null); | |
| setEditSubmissionText(''); | |
| await fetchUserSubmissions(weeklyPractice); | |
| } else { | |
| console.error('Failed to update translation:', response.data); | |
| } | |
| } catch (error) { | |
| console.error('Error updating translation:', error); | |
| } | |
| }; | |
| const cancelEditSubmission = () => { | |
| setEditingSubmission(null); | |
| setEditSubmissionText(''); | |
| }; | |
| const handleDeleteSubmission = async (submissionId: string) => { | |
| try { | |
| const response = await api.delete(`/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: '' | |
| }); | |
| }; | |
| const startEditingBrief = () => { | |
| setEditingBrief(prev => ({ ...prev, [selectedWeek]: true })); | |
| setEditForm({ | |
| content: '', | |
| translationBrief: weeklyPracticeWeek?.translationBrief || '' | |
| }); | |
| }; | |
| const startAddingBrief = () => { | |
| setEditingBrief(prev => ({ ...prev, [selectedWeek]: true })); | |
| setEditForm({ | |
| content: '', | |
| translationBrief: '' | |
| }); | |
| }; | |
| const removeBrief = async () => { | |
| try { | |
| setSaving(true); | |
| const token = localStorage.getItem('token'); | |
| const user = JSON.parse(localStorage.getItem('user') || '{}'); | |
| // Check if user is admin | |
| if (user.role !== 'admin') { | |
| return; | |
| } | |
| const response = await api.put(`/auth/admin/weekly-brief/${selectedWeek}`, { | |
| translationBrief: '', | |
| weekNumber: selectedWeek | |
| }); | |
| if (response.status >= 200 && response.status < 300) { | |
| await fetchWeeklyPractice(); | |
| } else { | |
| console.error('Failed to remove translation brief:', response.data); | |
| } | |
| } catch (error) { | |
| console.error('Failed to remove translation brief:', error); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const toggleExpanded = (practiceId: string) => { | |
| setExpandedSections(prev => ({ | |
| ...prev, | |
| [practiceId]: !prev[practiceId] | |
| })); | |
| }; | |
| const cancelEditing = () => { | |
| setEditingPractice(null); | |
| setEditingBrief(prev => ({ ...prev, [selectedWeek]: false })); | |
| setEditForm({ | |
| content: '', | |
| translationBrief: '' | |
| }); | |
| }; | |
| const savePractice = async () => { | |
| if (!editingPractice) return; | |
| try { | |
| setSaving(true); | |
| const token = localStorage.getItem('token'); | |
| const user = JSON.parse(localStorage.getItem('user') || '{}'); | |
| // Check if user is admin | |
| if (user.role !== 'admin') { | |
| return; | |
| } | |
| const response = await api.put(`/auth/admin/weekly-practice/${editingPractice}`, { | |
| ...editForm, | |
| weekNumber: selectedWeek | |
| }); | |
| if (response.status >= 200 && response.status < 300) { | |
| await fetchWeeklyPractice(); | |
| setEditingPractice(null); | |
| } else { | |
| console.error('Failed to update weekly practice:', response.data); | |
| } | |
| } catch (error) { | |
| console.error('Failed to update weekly practice:', error); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const saveBrief = async () => { | |
| try { | |
| setSaving(true); | |
| const token = localStorage.getItem('token'); | |
| const user = JSON.parse(localStorage.getItem('user') || '{}'); | |
| // Check if user is admin | |
| if (user.role !== 'admin') { | |
| return; | |
| } | |
| const response = await api.put(`/auth/admin/weekly-brief/${selectedWeek}`, { | |
| translationBrief: editForm.translationBrief, | |
| weekNumber: selectedWeek | |
| }); | |
| if (response.status >= 200 && response.status < 300) { | |
| await fetchWeeklyPractice(); | |
| setEditingBrief(prev => ({ ...prev, [selectedWeek]: false })); | |
| } else { | |
| console.error('Failed to update translation brief:', response.data); | |
| } | |
| } catch (error) { | |
| console.error('Failed to update translation brief:', error); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const startAddingPractice = () => { | |
| setAddingPractice(true); | |
| setEditForm({ | |
| content: '', | |
| translationBrief: '' | |
| }); | |
| }; | |
| const cancelAddingPractice = () => { | |
| setAddingPractice(false); | |
| setEditForm({ | |
| content: '', | |
| translationBrief: '' | |
| }); | |
| }; | |
| const saveNewPractice = async () => { | |
| try { | |
| setSaving(true); | |
| const token = localStorage.getItem('token'); | |
| const user = JSON.parse(localStorage.getItem('user') || '{}'); | |
| // Check if user is admin | |
| if (user.role !== 'admin') { | |
| return; | |
| } | |
| // 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 | |
| }; | |
| const response = await api.post('/auth/admin/weekly-practice', payload); | |
| if (response.status >= 200 && response.status < 300) { | |
| await fetchWeeklyPractice(); | |
| setAddingPractice(false); | |
| } else { | |
| console.error('Failed to add weekly practice:', response.data); | |
| } | |
| } catch (error) { | |
| console.error('Failed to add weekly practice:', error); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const deletePractice = async (practiceId: string) => { | |
| const user = JSON.parse(localStorage.getItem('user') || '{}'); | |
| const token = localStorage.getItem('token'); | |
| // Check if user is admin | |
| if (user.role !== 'admin') { | |
| return; | |
| } | |
| setSaving(true); | |
| try { | |
| const response = await api.delete(`/auth/admin/weekly-practice/${practiceId}`); | |
| if (response.status >= 200 && response.status < 300) { | |
| await fetchWeeklyPractice(); | |
| } else { | |
| console.error('Failed to delete weekly practice:', response.data); | |
| } | |
| } catch (error) { | |
| console.error('Failed to delete weekly practice:', error); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| if (loading) { | |
| return ( | |
| <div className="min-h-screen bg-gray-50 py-8"> | |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> | |
| <div className="text-center"> | |
| <div className="animate-pulse"> | |
| <div className="h-8 w-64 bg-gray-200 rounded mx-auto mb-4"></div> | |
| <div className="h-4 w-96 bg-gray-200 rounded mx-auto mb-2"></div> | |
| <div className="h-4 w-80 bg-gray-200 rounded mx-auto"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="min-h-screen bg-gray-50 py-8"> | |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> | |
| {/* Header */} | |
| <div className="mb-8"> | |
| <div className="flex items-center mb-4"> | |
| <BookOpenIcon className="h-8 w-8 text-indigo-600 mr-3" /> | |
| <h1 className="text-3xl font-bold text-gray-900">Weekly Practice</h1> | |
| </div> | |
| <p className="text-gray-600"> | |
| Practice your translation skills with weekly examples and cultural elements. | |
| </p> | |
| </div> | |
| {/* Week Selector */} | |
| <div className="mb-6"> | |
| <div className="flex space-x-2 overflow-x-auto pb-2"> | |
| {weeks.map((week) => ( | |
| <button | |
| key={week} | |
| onClick={() => { | |
| setSelectedWeek(week); | |
| localStorage.setItem('selectedWeeklyPracticeWeek', week.toString()); | |
| }} | |
| className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap ${ | |
| selectedWeek === week | |
| ? 'bg-indigo-600 text-white' | |
| : 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300' | |
| }`} | |
| > | |
| Week {week} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Translation Brief - Shown once at the top */} | |
| {weeklyPracticeWeek && weeklyPracticeWeek.translationBrief ? ( | |
| <div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-8 mb-8 border border-blue-200"> | |
| <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"> | |
| <BookOpenIcon className="h-5 w-5 text-blue-600" /> | |
| </div> | |
| <h3 className="text-blue-900 font-semibold text-xl">Translation Brief</h3> | |
| </div> | |
| {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && ( | |
| <div className="flex items-center space-x-2"> | |
| {editingBrief[selectedWeek] ? ( | |
| <> | |
| <button | |
| onClick={saveBrief} | |
| disabled={saving} | |
| className="bg-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-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={() => 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] ? ( | |
| <textarea | |
| value={editForm.translationBrief} | |
| onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })} | |
| className="w-full p-4 border border-blue-300 rounded-lg text-blue-800 leading-relaxed text-lg bg-white" | |
| rows={6} | |
| placeholder="Enter translation brief..." | |
| /> | |
| ) : ( | |
| <p className="text-blue-800 leading-relaxed text-lg font-smiley">{weeklyPracticeWeek.translationBrief}</p> | |
| )} | |
| </div> | |
| ) : ( | |
| // Show add brief button when no brief exists | |
| JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && ( | |
| <div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-8 mb-8 border border-blue-200 border-dashed"> | |
| <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"> | |
| <BookOpenIcon className="h-5 w-5 text-blue-600" /> | |
| </div> | |
| <h3 className="text-blue-900 font-semibold text-xl">Translation Brief</h3> | |
| </div> | |
| <button | |
| onClick={startAddingBrief} | |
| className="bg-blue-600 hover:bg-blue-700 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"> | |
| <textarea | |
| value={editForm.translationBrief} | |
| onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })} | |
| className="w-full p-4 border border-blue-300 rounded-lg text-blue-800 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> | |
| ) | |
| )} | |
| {/* Special Subtitling Interface for Week 2 */} | |
| {selectedWeek === 2 ? ( | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> | |
| {/* Left Panel - Video Player */} | |
| <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 mb-4 relative"> | |
| <video | |
| ref={setVideoRef} | |
| src="/videos/nike-winning-isnt-for-everyone.mp4" | |
| controls | |
| onTimeUpdate={() => { | |
| const currentTime = videoRef?.currentTime || 0; | |
| setCurrentTime(formatTime(currentTime)); | |
| }} | |
| onLoadedMetadata={() => { | |
| if (videoRef) { | |
| setTotalTime(formatTime(videoRef.duration)); | |
| } | |
| }} | |
| onPlay={() => setIsPlaying(true)} | |
| onPause={() => setIsPlaying(false)} | |
| className="w-full h-full" | |
| /> | |
| {/* Professional subtitle overlay - dynamically sized, precise timing */} | |
| {(previewText || subtitleText) && ( | |
| <div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 bg-black bg-opacity-70 text-white px-4 py-2 rounded-lg shadow-lg"> | |
| <p className="text-lg font-medium whitespace-nowrap">{previewText || subtitleText}</p> | |
| </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 */} | |
| <div className="grid grid-cols-8 gap-2 mb-6"> | |
| {subtitleSegments.map((segment) => ( | |
| <button | |
| key={segment.id} | |
| onClick={() => handleSegmentClick(segment.id)} | |
| className={`px-2 py-1 rounded text-xs ${getSegmentButtonClass(segment.id)}`} | |
| > | |
| Seg {segment.id} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Current Segment Details - Only show timecodes to admin users */} | |
| <div className="bg-gray-100 rounded-lg p-4 mb-4"> | |
| <p className="text-gray-600"> | |
| Segment {currentSegment} | |
| {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && ( | |
| <>: {subtitleSegments[currentSegment - 1]?.startTime} – {subtitleSegments[currentSegment - 1]?.endTime} ({subtitleSegments[currentSegment - 1]?.duration})</> | |
| )} | |
| </p> | |
| {/* Timecode Editing for Admin Users Only */} | |
| {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && ( | |
| <div className="mt-3 pt-3 border-t border-gray-300"> | |
| <button | |
| onClick={() => startEditingTimecodes(currentSegment)} | |
| className="bg-purple-500 hover:bg-purple-600 text-white px-3 py-1 rounded text-sm" | |
| > | |
| Edit Timecodes | |
| </button> | |
| <button | |
| onClick={resetToDefaultTimecodes} | |
| className="bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded text-sm ml-2" | |
| > | |
| Reset Default | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| {/* Timecode Editing Modal for Admin Only */} | |
| {editingTimecodes && editingSegment && JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && ( | |
| <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-96"> | |
| <h3 className="text-lg font-bold mb-4">Edit Timecodes for Segment {editingSegment}</h3> | |
| <div className="space-y-4"> | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-1">Start Time (HH:MM:SS,mmm)</label> | |
| <input | |
| type="text" | |
| value={editStartTime} | |
| onChange={(e) => setEditStartTime(e.target.value)} | |
| className="w-full px-3 py-2 border border-gray-300 rounded-md" | |
| placeholder="00:00:00,000" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-1">End Time (HH:MM:SS,mmm)</label> | |
| <input | |
| type="text" | |
| value={editEndTime} | |
| onChange={(e) => setEditEndTime(e.target.value)} | |
| className="w-full px-3 py-2 border border-gray-300 rounded-md" | |
| placeholder="00:00:00,000" | |
| /> | |
| </div> | |
| <div className="flex space-x-3"> | |
| <button | |
| onClick={saveTimecodes} | |
| className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded" | |
| > | |
| Save | |
| </button> | |
| <button | |
| onClick={cancelEditingTimecodes} | |
| className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded" | |
| > | |
| Cancel | |
| </button> | |
| </div> | |
| </div> | |
| </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-700" | |
| 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-orange-500 focus:border-orange-500" | |
| rows={2} | |
| placeholder="Enter your translation..." | |
| /> | |
| <div className="flex justify-between items-center mt-2"> | |
| <span className="text-xs text-gray-500">Recommended: 2 lines max, 16 chars/line</span> | |
| <span className="text-xs text-gray-500">{characterCount} characters</span> | |
| </div> | |
| </div> | |
| {/* Action Buttons */} | |
| <div className="flex space-x-3 mb-4"> | |
| <button | |
| onClick={handleSaveAndNext} | |
| className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg transition-colors" | |
| > | |
| Save | |
| </button> | |
| <button | |
| onClick={handlePreviewTranslation} | |
| className={`px-4 py-2 rounded-lg flex items-center space-x-2 ${ | |
| isPreviewMode | |
| ? 'bg-green-500 hover:bg-green-600 text-white' | |
| : 'bg-orange-500 hover:bg-orange-600 text-white' | |
| }`} | |
| > | |
| <span>{isPreviewMode ? '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-500">{savedSegments.size} 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: `${(savedSegments.size / subtitleSegments.length) * 100}%` }} | |
| ></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ) : ( | |
| /* Weekly Practice */ | |
| <div className="space-y-6"> | |
| {/* Add Practice Button for Admin */} | |
| {JSON.parse(localStorage.getItem('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> | |
| <textarea | |
| 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..." | |
| /> | |
| </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> | |
| <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> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {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> | |
| ) : ( | |
| 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-orange-100 rounded-full p-2"> | |
| <DocumentTextIcon className="h-5 w-5 text-orange-600" /> | |
| </div> | |
| <div> | |
| <h3 className="text-lg font-semibold text-gray-900">Source Text #{weeklyPractice.indexOf(practice) + 1}</h3> | |
| </div> | |
| </div> | |
| {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 - Enhanced styling */} | |
| <div className="bg-gradient-to-r from-orange-50 to-amber-50 rounded-xl p-6 mb-6 border border-orange-200"> | |
| {editingPractice === practice._id ? ( | |
| <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..." | |
| /> | |
| ) : ( | |
| <p className="text-orange-800 leading-relaxed text-lg font-source-text">{practice.content}</p> | |
| )} | |
| </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">{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 && ( | |
| <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 (only show if user is logged in and has no submission) */} | |
| {(() => { | |
| const hasToken = localStorage.getItem('token'); | |
| const allSubs = userSubmissions[practice._id] || []; | |
| const currentUser = JSON.parse(localStorage.getItem('user') || '{}'); | |
| const currentUserSubs = allSubs.filter(sub => sub.userId?._id === currentUser._id); | |
| return hasToken && currentUserSubs.length === 0; | |
| })() && ( | |
| <div className="bg-gradient-to-r from-purple-50 to-violet-50 rounded-xl p-6 border border-purple-200"> | |
| <div className="flex items-center space-x-2 mb-4"> | |
| <div className="bg-purple-100 rounded-full p-1"> | |
| <DocumentTextIcon className="h-4 w-4 text-purple-600" /> | |
| </div> | |
| <h4 className="text-purple-900 font-semibold text-lg">Your Translation</h4> | |
| </div> | |
| <div className="mb-4"> | |
| <textarea | |
| value={translationText[practice._id] || ''} | |
| onChange={(e) => setTranslationText({ ...translationText, [practice._id]: e.target.value })} | |
| className="w-full px-4 py-3 border border-purple-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-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-purple-700 font-medium">Submit anonymously</span> | |
| </label> | |
| <p className="text-sm text-purple-600 mt-1"> | |
| Check this box to submit without showing your name | |
| </p> | |
| </div> | |
| <button | |
| onClick={() => handleSubmitTranslation(practice._id)} | |
| disabled={submitting[practice._id]} | |
| className="bg-gradient-to-r from-purple-600 to-violet-600 hover:from-purple-700 hover:to-violet-700 disabled:from-gray-400 disabled:to-gray-500 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center transition-all duration-200 transform hover:scale-105" | |
| > | |
| {submitting[practice._id] ? ( | |
| <> | |
| <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> | |
| Submitting... | |
| </> | |
| ) : ( | |
| <> | |
| Submit Translation | |
| <ArrowRightIcon className="h-4 w-4 ml-2" /> | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| )} | |
| {/* Show login message for visitors */} | |
| {!localStorage.getItem('token') && ( | |
| <div className="bg-gradient-to-r from-gray-50 to-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> | |
| )} | |
| </div> | |
| {/* Edit Submission Modal */} | |
| {editingSubmission && ( | |
| <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> | |
| <div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <h3 className="text-lg font-semibold text-gray-900">Edit Translation</h3> | |
| <button | |
| onClick={cancelEditSubmission} | |
| className="text-gray-400 hover:text-gray-600" | |
| > | |
| <XMarkIcon className="h-6 w-6" /> | |
| </button> | |
| </div> | |
| <div className="mb-4"> | |
| <textarea | |
| value={editSubmissionText} | |
| onChange={(e) => setEditSubmissionText(e.target.value)} | |
| className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" | |
| rows={6} | |
| placeholder="Enter your translation..." | |
| /> | |
| </div> | |
| <div className="flex justify-end space-x-3"> | |
| <button | |
| onClick={cancelEditSubmission} | |
| className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg" | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| onClick={saveEditedSubmission} | |
| className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700" | |
| > | |
| Save Changes | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default WeeklyPractice; |