|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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}>({}); |
|
|
|
|
|
|
|
|
const renderFormatted = (text: string) => { |
|
|
const escape = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
|
|
const html = escape(text) |
|
|
.replace(/\[([^\]]+?)\]\((https?:\/\/[^\s)]+)\)/g, '<a class="text-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); |
|
|
}; |
|
|
|
|
|
|
|
|
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) { } |
|
|
}; |
|
|
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(); |
|
|
|
|
|
|
|
|
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>(''); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
setWeeklyPractice([]); |
|
|
setWeeklyPracticeWeek(null); |
|
|
setUserSubmissions({}); |
|
|
localStorage.setItem('selectedWeeklyPracticeWeek', week.toString()); |
|
|
setSelectedWeek(week); |
|
|
|
|
|
try { |
|
|
|
|
|
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); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const downloadWeekFile = async (fileId: string, fallbackName?: string) => { |
|
|
try { |
|
|
const response = await api.get(`/api/weekly-practice-files/${fileId}/download`, { responseType: 'blob' }); |
|
|
|
|
|
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); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
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 |
|
|
}; |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
const checkEndTime = () => { |
|
|
if (video.currentTime >= endTime) { |
|
|
video.pause(); |
|
|
video.removeEventListener('timeupdate', checkEndTime); |
|
|
} |
|
|
}; |
|
|
video.addEventListener('timeupdate', checkEndTime); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (showSubmissions) { |
|
|
fetchSubmissionsForSegment(segmentId); |
|
|
} |
|
|
}; |
|
|
|
|
|
const parseTimeToSeconds = (timeString: string): number => { |
|
|
|
|
|
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 => { |
|
|
|
|
|
const maxCharsPerLine = 60; |
|
|
|
|
|
|
|
|
const normalizedText = text.replace(/\n/g, ' '); |
|
|
|
|
|
|
|
|
if (normalizedText.length <= maxCharsPerLine) { |
|
|
return normalizedText; |
|
|
} |
|
|
|
|
|
|
|
|
const words = normalizedText.split(' '); |
|
|
const lines: string[] = []; |
|
|
let currentLine = ''; |
|
|
|
|
|
words.forEach(word => { |
|
|
|
|
|
if ((currentLine + ' ' + word).length > maxCharsPerLine) { |
|
|
|
|
|
if (currentLine) { |
|
|
lines.push(currentLine); |
|
|
currentLine = word; |
|
|
} else { |
|
|
|
|
|
if (word.length > maxCharsPerLine) { |
|
|
|
|
|
const firstPart = word.substring(0, maxCharsPerLine); |
|
|
const secondPart = word.substring(maxCharsPerLine); |
|
|
lines.push(firstPart); |
|
|
currentLine = secondPart; |
|
|
} else { |
|
|
currentLine = word; |
|
|
} |
|
|
} |
|
|
} else { |
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
const currentSegment = subtitleSegments.find(segment => { |
|
|
const startTime = parseTimeToSeconds(segment.startTime); |
|
|
const endTime = parseTimeToSeconds(segment.endTime); |
|
|
|
|
|
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]; |
|
|
} |
|
|
|
|
|
|
|
|
const formattedText = formatSubtitleForDisplay(subtitleText); |
|
|
console.log('🔧 Setting subtitle text:', formattedText); |
|
|
setCurrentDisplayedSubtitle(formattedText); |
|
|
} else { |
|
|
|
|
|
console.log('🔧 No segment found for current time, clearing subtitle'); |
|
|
setCurrentDisplayedSubtitle(''); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
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 = () => { |
|
|
|
|
|
console.log('Saving translation for segment:', currentSegment, targetText); |
|
|
|
|
|
if (currentSegment < subtitleSegments.length) { |
|
|
handleSegmentClick(currentSegment + 1); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handlePrevious = () => { |
|
|
if (currentSegment > 1) { |
|
|
handleSegmentClick(currentSegment - 1); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handlePreviewTranslation = () => { |
|
|
|
|
|
const newShowTranslated = !showTranslatedSubtitles; |
|
|
setShowTranslatedSubtitles(newShowTranslated); |
|
|
|
|
|
|
|
|
if (currentSegment > 0 && subtitleSegments[currentSegment - 1]) { |
|
|
const segment = subtitleSegments[currentSegment - 1]; |
|
|
let subtitleText = segment.sourceText; |
|
|
|
|
|
if (newShowTranslated && subtitleTranslations[currentSegment]) { |
|
|
subtitleText = subtitleTranslations[currentSegment]; |
|
|
} |
|
|
|
|
|
|
|
|
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...'); |
|
|
|
|
|
|
|
|
setSubtitleTranslations(prev => ({ |
|
|
...prev, |
|
|
[currentSegment]: targetText |
|
|
})); |
|
|
|
|
|
console.log('💾 Saving translation for segment:', currentSegment, targetText); |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
|
|
|
try { |
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}'); |
|
|
console.log('🔧 Submitting translation as submission for user:', user.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'); |
|
|
|
|
|
if (showSubmissions) { |
|
|
await fetchSubmissionsForSegment(currentSegment); |
|
|
} |
|
|
} |
|
|
} catch (submissionError: any) { |
|
|
console.error('❌ Submission save failed:', submissionError.response?.data || submissionError.message); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} 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 { |
|
|
|
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}'); |
|
|
|
|
|
console.log('🔧 Debug - User:', user); |
|
|
|
|
|
if (user.role !== 'admin') { |
|
|
console.error('❌ User is not admin'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
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 }); |
|
|
|
|
|
|
|
|
setSubtitleSegments(prev => prev.map(segment => |
|
|
segment.id === segmentId |
|
|
? { ...segment, startTime, endTime, duration } |
|
|
: segment |
|
|
)); |
|
|
|
|
|
|
|
|
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); |
|
|
}; |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
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); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const fetchSubmissionsForSegment = async (segmentId: number) => { |
|
|
try { |
|
|
setLoadingSubmissions(true); |
|
|
console.log('🔧 Fetching submissions for segment:', segmentId); |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const toggleSubmissions = async () => { |
|
|
if (!showSubmissions) { |
|
|
|
|
|
setShowSubmissions(true); |
|
|
await fetchSubmissionsForSegment(currentSegment); |
|
|
} else { |
|
|
|
|
|
setShowSubmissions(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const handleDeleteSubtitleSubmission = async (submissionId: string) => { |
|
|
try { |
|
|
console.log('🗑️ Deleting subtitle submission:', submissionId); |
|
|
|
|
|
|
|
|
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'); |
|
|
|
|
|
if (showSubmissions) { |
|
|
await fetchSubmissionsForSegment(currentSegment); |
|
|
} |
|
|
} |
|
|
} catch (error: any) { |
|
|
console.error('❌ Error deleting subtitle submission:', error.response?.data || error.message); |
|
|
} finally { |
|
|
|
|
|
const deleteButton = document.querySelector(`[data-submission-id="${submissionId}"]`) as HTMLButtonElement; |
|
|
if (deleteButton) { |
|
|
deleteButton.innerHTML = '🗑️'; |
|
|
deleteButton.disabled = false; |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
if (selectedWeek === 2) { |
|
|
fetchSubtitles(); |
|
|
} |
|
|
}, [selectedWeek]); |
|
|
|
|
|
|
|
|
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[]} = {}; |
|
|
|
|
|
|
|
|
practice.forEach(practice => { |
|
|
groupedSubmissions[practice._id] = []; |
|
|
}); |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
}, []); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
if (practices.length > 0) { |
|
|
let translationBrief = practices[0].translationBrief; |
|
|
|
|
|
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 { |
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
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); |
|
|
}; |
|
|
}, []); |
|
|
|
|
|
|
|
|
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') || '{}'); |
|
|
|
|
|
|
|
|
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') || '{}'); |
|
|
|
|
|
|
|
|
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') || '{}'); |
|
|
|
|
|
|
|
|
if (user.role !== 'admin') { |
|
|
return; |
|
|
} |
|
|
|
|
|
const response = await api.put(`/api/auth/admin/weekly-brief/${selectedWeek}`, { |
|
|
translationBrief: editForm.translationBrief, |
|
|
weekNumber: selectedWeek |
|
|
}); |
|
|
|
|
|
if (response.data) { |
|
|
|
|
|
setWeeklyPracticeWeek(prev => prev ? { ...prev, translationBrief: editForm.translationBrief } : prev); |
|
|
setEditingBrief(prev => ({ ...prev, [selectedWeek]: false })); |
|
|
|
|
|
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') || '{}'); |
|
|
|
|
|
|
|
|
if (user.role !== 'admin') { |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
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, |
|
|
|
|
|
...(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') || '{}'); |
|
|
|
|
|
|
|
|
if (user.role !== 'admin') { |
|
|
return; |
|
|
} |
|
|
|
|
|
if (!imageForm.imageUrl.trim()) { |
|
|
return; |
|
|
} |
|
|
|
|
|
const payload = { |
|
|
title: `Week ${selectedWeek} Image Practice`, |
|
|
content: '', |
|
|
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); |
|
|
|
|
|
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') || '{}'); |
|
|
|
|
|
|
|
|
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> |
|
|
) : ( |
|
|
|
|
|
(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> |
|
|
) |
|
|
)} |
|
|
|
|
|
{} |
|
|
{(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> |
|
|
)} |
|
|
|
|
|
{} |
|
|
{!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"> |
|
|
{} |
|
|
{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> |
|
|
)} |
|
|
{} |
|
|
{(() => { |
|
|
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> |
|
|
|
|
|
{} |
|
|
{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; |