| import React, { useState, useEffect, useMemo } from 'react'; |
| import { useNavigate } from 'react-router-dom'; |
| import { |
| HandThumbUpIcon, |
| MagnifyingGlassIcon, |
| XMarkIcon |
| } from '@heroicons/react/24/outline'; |
| import { api } from '../services/api'; |
|
|
| interface VoteableSubmission { |
| _id: string; |
| transcreation: string; |
| score: number; |
| voteCounts: { |
| '1': number; |
| '2': number; |
| '3': number; |
| }; |
| hasVoted: boolean; |
| userRank?: number; |
| groupNumber?: number; |
| isGroupSubmission?: boolean; |
| sourceTextId: { |
| _id: string; |
| title: string; |
| content: string; |
| sourceLanguage: string; |
| sourceCulture: string; |
| category: string; |
| weekNumber: number; |
| }; |
| } |
|
|
| interface GroupedSubmissions { |
| [sourceTextId: string]: { |
| sourceText: { |
| _id: string; |
| title: string; |
| content: string; |
| sourceLanguage: string; |
| sourceCulture: string; |
| category: string; |
| weekNumber: number; |
| }; |
| submissions: VoteableSubmission[]; |
| }; |
| } |
|
|
| const VoteResults: React.FC = () => { |
| const [groupedSubmissions, setGroupedSubmissions] = useState<GroupedSubmissions>({}); |
| const [selectedExample, setSelectedExample] = useState<string | null>(null); |
| const [loading, setLoading] = useState(true); |
| const [voting, setVoting] = useState<{[key: string]: boolean}>({}); |
| const [searchTerm, setSearchTerm] = useState(''); |
| const [sortBy, setSortBy] = useState<'score' | 'votes' | 'st-number'>('score'); |
| const [filterCategory, setFilterCategory] = useState<string>('all'); |
| const [filterWeek, setFilterWeek] = useState<string>('all'); |
| const navigate = useNavigate(); |
|
|
| useEffect(() => { |
| const user = localStorage.getItem('user'); |
| if (!user) { |
| navigate('/login'); |
| return; |
| } |
| fetchVoteResults(); |
| }, [navigate]); |
|
|
| const fetchVoteResults = async (showLoading = true) => { |
| try { |
| if (showLoading) { setLoading(true); } |
| const response = await api.get('/api/submissions/voteable'); |
|
|
| if (response.data) { |
| const data = response.data; |
| |
| |
| const transformedData: GroupedSubmissions = {}; |
| |
| if (data.examples && Array.isArray(data.examples)) { |
| data.examples.forEach((exampleGroup: any) => { |
| const sourceTextId = exampleGroup.example.id; |
| transformedData[sourceTextId] = { |
| sourceText: { |
| _id: sourceTextId, |
| title: exampleGroup.example.title || `Example ${sourceTextId.slice(-4)}`, |
| content: exampleGroup.example.content, |
| sourceLanguage: exampleGroup.example.language, |
| sourceCulture: exampleGroup.example.culture, |
| category: exampleGroup.example.category || 'tutorial', |
| weekNumber: exampleGroup.example.weekNumber || 1 |
| }, |
| submissions: exampleGroup.translations.map((translation: any) => ({ |
| _id: translation.id, |
| transcreation: translation.translation, |
| score: translation.score, |
| voteCounts: translation.voteCounts, |
| hasVoted: translation.hasVoted, |
| userRank: translation.userRank, |
| groupNumber: translation.groupNumber, |
| isGroupSubmission: translation.isGroupSubmission, |
| sourceTextId: { |
| _id: sourceTextId, |
| title: exampleGroup.example.title || `Example ${sourceTextId.slice(-4)}`, |
| content: exampleGroup.example.content, |
| sourceLanguage: exampleGroup.example.language, |
| sourceCulture: exampleGroup.example.culture, |
| category: exampleGroup.example.category || 'tutorial', |
| weekNumber: exampleGroup.example.weekNumber || 1 |
| } |
| })) |
| }; |
| }); |
| } |
| |
| setGroupedSubmissions(transformedData); |
| } else { |
| console.error('Failed to fetch vote results'); |
| } |
| } catch (error) { |
| console.error('Error fetching vote results:', error); |
| } finally { |
| if (showLoading) { setLoading(false); } |
| } |
| }; |
|
|
| const handleVote = async (submissionId: string, rank: number | null) => { |
| try { |
| setVoting({ ...voting, [submissionId]: true }); |
| |
| const body = rank ? { rank } : { cancel: true }; |
| |
| const response = await api.post(`/api/submissions/${submissionId}/vote`, body); |
|
|
| if (response.data) { |
| |
| await fetchVoteResults(false); |
| } else { |
| console.error('Failed to submit vote'); |
| } |
| } catch (error) { |
| console.error('Error submitting vote:', error); |
| } finally { |
| setVoting({ ...voting, [submissionId]: false }); |
| } |
| }; |
|
|
| const getFilteredTranslations = (submissions: VoteableSubmission[] | undefined) => { |
| if (!submissions || !Array.isArray(submissions)) { |
| return []; |
| } |
| |
| let filtered = submissions; |
|
|
| |
| if (searchTerm) { |
| filtered = filtered.filter(sub => |
| sub.transcreation.toLowerCase().includes(searchTerm.toLowerCase()) |
| ); |
| } |
|
|
| |
| filtered.sort((a, b) => { |
| let aValue: number; |
| let bValue: number; |
|
|
| switch (sortBy) { |
| case 'score': |
| aValue = a.score || 0; |
| bValue = b.score || 0; |
| break; |
| case 'votes': |
| aValue = (a.voteCounts?.['1'] || 0) + (a.voteCounts?.['2'] || 0) + (a.voteCounts?.['3'] || 0); |
| bValue = (b.voteCounts?.['1'] || 0) + (b.voteCounts?.['2'] || 0) + (b.voteCounts?.['3'] || 0); |
| break; |
| |
| default: |
| aValue = a.score || 0; |
| bValue = b.score || 0; |
| } |
|
|
| return bValue - aValue; |
| }); |
|
|
| return filtered; |
| }; |
|
|
| const getUserVotingProgress = (submissions: VoteableSubmission[] | undefined) => { |
| if (!submissions || !Array.isArray(submissions)) { |
| return { |
| voted: 0, |
| total: 3, |
| percentage: 0 |
| }; |
| } |
| const userVotes = submissions.filter(sub => sub.hasVoted).length; |
| return { |
| voted: userVotes, |
| total: 3, |
| percentage: Math.min((userVotes / 3) * 100, 100) |
| }; |
| }; |
|
|
| const getAvailableRanks = (submissions: VoteableSubmission[] | undefined) => { |
| if (!submissions || !Array.isArray(submissions)) { |
| return [1, 2, 3]; |
| } |
| const usedRanks = new Set(submissions.filter(sub => sub.hasVoted).map(sub => sub.userRank)); |
| return [1, 2, 3].filter(rank => !usedRanks.has(rank)); |
| }; |
|
|
| |
| const extractStNumber = (title: string | undefined) => { |
| if (!title) return Number.POSITIVE_INFINITY; |
| const m = title.match(/ST\s*(\d+)/i); |
| return m ? parseInt(m[1], 10) : Number.POSITIVE_INFINITY; |
| }; |
|
|
| const objectIdTime = (id: string | undefined) => { |
| if (!id) return Number.POSITIVE_INFINITY; |
| try { return parseInt(id.slice(0, 8), 16); } catch { return Number.POSITIVE_INFINITY; } |
| }; |
|
|
| const orderedKeysByWeekCategory = useMemo(() => { |
| const map: {[key: string]: string[]} = {}; |
| const keys = Object.keys(groupedSubmissions); |
| keys.forEach((k) => { |
| const st = groupedSubmissions[k]?.sourceText; |
| if (!st) return; |
| const wk = st.weekNumber || 0; |
| const cat = st.category || 'tutorial'; |
| const bucketKey = `${wk}__${cat}`; |
| if (!map[bucketKey]) map[bucketKey] = []; |
| map[bucketKey].push(k); |
| }); |
| |
| Object.keys(map).forEach((bucketKey) => { |
| map[bucketKey].sort((a, b) => { |
| const A = groupedSubmissions[a]?.sourceText; |
| const B = groupedSubmissions[b]?.sourceText; |
| const aNum = extractStNumber(A?.title); |
| const bNum = extractStNumber(B?.title); |
| if (aNum !== bNum) return aNum - bNum; |
| const aTime = objectIdTime(A?._id); |
| const bTime = objectIdTime(B?._id); |
| if (aTime !== bTime) return aTime - bTime; |
| return (A?.title || '').localeCompare(B?.title || ''); |
| }); |
| }); |
| return map; |
| }, [groupedSubmissions]); |
|
|
| const getIndexForKey = (key: string) => { |
| const st = groupedSubmissions[key]?.sourceText; |
| if (!st) return 0; |
| const bucketKey = `${st.weekNumber || 0}__${st.category || 'tutorial'}`; |
| const arr = orderedKeysByWeekCategory[bucketKey] || []; |
| const idx = arr.indexOf(key); |
| return idx >= 0 ? idx : 0; |
| }; |
|
|
| const getDisplayTitle = (sourceText: any, index: number, weekNumber: number) => { |
| if (!sourceText) return 'Untitled'; |
| if (sourceText.category === 'tutorial' || sourceText.category === 'weekly-practice') { |
| return `Source Text ${index + 1}`; |
| } |
| return sourceText.title || 'Untitled'; |
| }; |
|
|
|
|
|
|
| const exampleKeys = Object.keys(groupedSubmissions); |
| |
| const filteredExamples = exampleKeys.filter(key => { |
| const example = groupedSubmissions[key]; |
| if (!example || !example.sourceText) return false; |
| |
| if (filterCategory !== 'all' && example.sourceText.category !== filterCategory) return false; |
| if (filterWeek !== 'all' && example.sourceText.weekNumber.toString() !== filterWeek) return false; |
| return true; |
| }).sort((a, b) => { |
| const exampleA = groupedSubmissions[a]; |
| const exampleB = groupedSubmissions[b]; |
| |
| |
| if (exampleA.sourceText.weekNumber !== exampleB.sourceText.weekNumber) { |
| return exampleA.sourceText.weekNumber - exampleB.sourceText.weekNumber; |
| } |
| |
| |
| if (exampleA.sourceText.category !== exampleB.sourceText.category) { |
| if (exampleA.sourceText.category === 'tutorial') return -1; |
| if (exampleB.sourceText.category === 'tutorial') return 1; |
| } |
| |
| |
| if (sortBy === 'st-number') { |
| const aNum = extractStNumber(exampleA.sourceText.title); |
| const bNum = extractStNumber(exampleB.sourceText.title); |
| if (aNum !== bNum) return aNum - bNum; |
| const aTime = objectIdTime(exampleA.sourceText._id); |
| const bTime = objectIdTime(exampleB.sourceText._id); |
| if (aTime !== bTime) return aTime - bTime; |
| } |
| |
| return (exampleA.sourceText.title || '').localeCompare(exampleB.sourceText.title || ''); |
| }); |
|
|
| |
| useEffect(() => { |
| if (selectedExample && !filteredExamples.includes(selectedExample)) { |
| setSelectedExample(null); |
| } |
| }, [selectedExample, filteredExamples, filterCategory, filterWeek, groupedSubmissions]); |
|
|
| 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/votes.svg" alt="Vote Results" className="h-8 w-8 mr-3" /> |
| <h1 className="text-3xl font-bold text-gray-900">Vote Results</h1> |
| </div> |
| <p className="text-gray-600"> |
| Vote on your favorite translations for each example. Rank your top 3 choices. |
| </p> |
| </div> |
| |
| {/* Instructions */} |
| <div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4"> |
| <h3 className="text-lg font-medium text-blue-900 mb-2">How Ranking Works</h3> |
| <div className="text-blue-800 text-sm space-y-2"> |
| <p><strong>Voting System:</strong> You can vote for up to 3 translations per example, ranking them 1st, 2nd, and 3rd place.</p> |
| <p><strong>Scoring:</strong> 1st place votes = 3 points, 2nd place votes = 2 points, 3rd place votes = 1 point.</p> |
| <p><strong>Final Score:</strong> Total points from all votes determine the ranking.</p> |
| <p><strong>Voting Rules:</strong> You can only vote once per example, and you can change your votes at any time.</p> |
| </div> |
| </div> |
| |
| {/* Filters */} |
| <div className="mb-6 bg-white rounded-lg shadow-sm border border-gray-200 p-4"> |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> |
| <div> |
| <label className="block text-sm font-medium text-gray-700 mb-1">Category</label> |
| <select |
| value={filterCategory} |
| onChange={(e) => setFilterCategory(e.target.value)} |
| className="w-full px-3 py-2 border border-gray-300 rounded-md" |
| > |
| <option value="all">All Categories</option> |
| <option value="tutorial">Tutorial Tasks</option> |
| <option value="weekly-practice">Weekly Practice</option> |
| </select> |
| </div> |
| <div> |
| <label className="block text-sm font-medium text-gray-700 mb-1">Week</label> |
| <select |
| value={filterWeek} |
| onChange={(e) => setFilterWeek(e.target.value)} |
| className="w-full px-3 py-2 border border-gray-300 rounded-md" |
| > |
| <option value="all">All Weeks</option> |
| {[1, 2, 3, 4, 5, 6].map(week => ( |
| <option key={week} value={week.toString()}>Week {week}</option> |
| ))} |
| </select> |
| </div> |
| <div> |
| <label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label> |
| <select |
| value={sortBy} |
| onChange={(e) => setSortBy(e.target.value as 'score' | 'votes' | 'st-number')} |
| className="w-full px-3 py-2 border border-gray-300 rounded-md" |
| > |
| <option value="st-number">ST Number</option> |
| <option value="score">Score</option> |
| <option value="votes">Total Votes</option> |
| </select> |
| </div> |
| |
| </div> |
| </div> |
| |
| {/* Source Text Selection */} |
| {filteredExamples.length > 0 ? ( |
| <div className="mb-6"> |
| <h2 className="text-lg font-medium text-gray-900 mb-4">Select a Source Text</h2> |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> |
| {filteredExamples.map((key) => { |
| const example = groupedSubmissions[key]; |
| if (!example || !example.sourceText) { |
| return null; // Skip rendering if example or sourceText is undefined |
| } |
| const progress = getUserVotingProgress(example.submissions); |
| const weekSpecificIndex = getIndexForKey(key); |
| |
| return ( |
| <button |
| key={key} |
| onClick={() => setSelectedExample(key)} |
| className={`p-4 rounded-lg border-2 text-left transition-colors ${ |
| selectedExample === key |
| ? 'border-indigo-500 bg-indigo-50' |
| : 'border-gray-200 bg-white hover:border-gray-300' |
| }`} |
| > |
| <div className="flex items-center justify-between mb-2"> |
| <h3 className="font-medium text-gray-900 font-source-text line-clamp-1"> |
| {getDisplayTitle(example.sourceText, weekSpecificIndex, example.sourceText.weekNumber)} |
| </h3> |
| <span className={`text-xs px-2 py-1 rounded ${ |
| example.sourceText.category === 'tutorial' |
| ? 'bg-blue-100 text-blue-800' |
| : 'bg-green-100 text-green-800' |
| }`}> |
| {example.sourceText.category === 'tutorial' ? 'Tutorial' : 'Practice'} |
| </span> |
| </div> |
| <p className="text-sm text-gray-600 line-clamp-2 mb-2"> |
| {example.sourceText.content || 'No content available'} |
| </p> |
| <div className="flex items-center justify-between text-xs text-gray-500"> |
| <span>Week {example.sourceText.weekNumber || 'N/A'}</span> |
| <span>{progress.voted}/3 votes cast</span> |
| </div> |
| <div className="mt-2 bg-gray-200 rounded-full h-2"> |
| <div |
| className="bg-indigo-600 h-2 rounded-full transition-all" |
| style={{ width: `${progress.percentage}%` }} |
| ></div> |
| </div> |
| </button> |
| ); |
| })} |
| </div> |
| </div> |
| ) : ( |
| <div className="mb-6 bg-white rounded-lg shadow-sm border border-gray-200 p-6"> |
| <div className="text-center"> |
| <h3 className="text-lg font-medium text-gray-900 mb-2">No Examples Found</h3> |
| <p className="text-gray-600"> |
| No submissions found for the selected filters. Try adjusting your category or week selection. |
| </p> |
| </div> |
| </div> |
| )} |
| |
| {/* Voting Section - show only after a source text is selected and still matches filters */} |
| {selectedExample && filteredExamples.includes(selectedExample) && groupedSubmissions[selectedExample] && groupedSubmissions[selectedExample].submissions && groupedSubmissions[selectedExample].sourceText && ( |
| <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6"> |
| <div className="mb-6"> |
| <h2 className="text-xl font-semibold text-gray-900 mb-2"> |
| {getDisplayTitle( |
| groupedSubmissions[selectedExample].sourceText, |
| getIndexForKey(selectedExample), |
| groupedSubmissions[selectedExample].sourceText.weekNumber |
| )} |
| </h2> |
| <div className="flex items-center space-x-4 text-sm text-gray-600 mb-3"> |
| <span className="bg-indigo-100 text-indigo-800 px-2 py-1 rounded"> |
| {groupedSubmissions[selectedExample].sourceText.sourceLanguage || 'Unknown'} |
| </span> |
| <span className={`px-2 py-1 rounded ${ |
| groupedSubmissions[selectedExample].sourceText.category === 'tutorial' |
| ? 'bg-blue-100 text-blue-800' |
| : 'bg-green-100 text-green-800' |
| }`}> |
| {groupedSubmissions[selectedExample].sourceText.category === 'tutorial' ? 'Tutorial' : 'Practice'} |
| </span> |
| <span>Week {groupedSubmissions[selectedExample].sourceText.weekNumber || 'N/A'}</span> |
| </div> |
| <div className="bg-gray-50 rounded-lg p-4"> |
| <p className="text-gray-900 font-source-text">{groupedSubmissions[selectedExample].sourceText.content || 'No content available'}</p> |
| </div> |
| </div> |
| |
| {/* Search */} |
| <div className="mb-4"> |
| <div className="relative"> |
| <MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" /> |
| <input |
| type="text" |
| placeholder="Search translations..." |
| value={searchTerm} |
| onChange={(e) => setSearchTerm(e.target.value)} |
| className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500" |
| /> |
| </div> |
| </div> |
| |
| {/* Instructions */} |
| <div className="mb-4 p-4 bg-blue-50 rounded-lg"> |
| <p className="text-sm text-blue-800"> |
| <strong>Instructions:</strong> Vote for your top 3 favorite translations. Click the vote buttons to rank them (1st, 2nd, 3rd place). |
| You can change your votes or cancel them by clicking the same button again. |
| </p> |
| </div> |
| |
| {/* Translations Grid */} |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> |
| {getFilteredTranslations(groupedSubmissions[selectedExample]?.submissions).map((submission) => { |
| const availableRanks = getAvailableRanks(groupedSubmissions[selectedExample]?.submissions); |
| |
| return ( |
| <div key={submission._id} className="border border-gray-200 rounded-lg p-4"> |
| <div className="flex items-start justify-between mb-3"> |
| <div className="flex-1"> |
| <p className="text-gray-900 mb-2 font-smiley">{submission.transcreation}</p> |
| {submission.isGroupSubmission && submission.groupNumber && ( |
| <div className="mb-2"> |
| <span className="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full"> |
| Group {submission.groupNumber} |
| </span> |
| </div> |
| )} |
| |
| <div className="flex items-center space-x-4 text-xs text-gray-500"> |
| <span>Score: {submission.score}</span> |
| <span>Votes: {submission.voteCounts['1'] + submission.voteCounts['2'] + submission.voteCounts['3']}</span> |
| </div> |
| </div> |
| |
| {/* Vote Buttons */} |
| <div className="flex flex-col space-y-1 ml-4"> |
| {[1, 2, 3].map((rank) => { |
| const isVoted = submission.hasVoted && submission.userRank === rank; |
| const isAvailable = !submission.hasVoted && availableRanks.includes(rank); |
| const isDisabled = !isVoted && !isAvailable; |
| |
| return ( |
| <button |
| key={rank} |
| onClick={() => handleVote(submission._id, isVoted ? null : rank)} |
| disabled={isDisabled || voting[submission._id]} |
| className={`w-8 h-8 rounded-full text-xs font-medium flex items-center justify-center transition-colors ${ |
| isVoted |
| ? 'bg-indigo-600 text-white' |
| : isAvailable |
| ? 'bg-gray-100 text-gray-700 hover:bg-gray-200' |
| : 'bg-gray-50 text-gray-400 cursor-not-allowed' |
| }`} |
| > |
| {isVoted ? <XMarkIcon className="h-3 w-3" /> : rank} |
| </button> |
| ); |
| })} |
| </div> |
| </div> |
| |
| {/* Vote Counts */} |
| <div className="flex items-center space-x-4 text-xs text-gray-500 mt-2"> |
| <span>1st: {submission.voteCounts['1']}</span> |
| <span>2nd: {submission.voteCounts['2']}</span> |
| <span>3rd: {submission.voteCounts['3']}</span> |
| </div> |
| </div> |
| ); |
| })} |
| </div> |
| |
| {getFilteredTranslations(groupedSubmissions[selectedExample]?.submissions).length === 0 && ( |
| <div className="text-center py-8"> |
| <p className="text-gray-500">No translations found matching your search criteria.</p> |
| </div> |
| )} |
| </div> |
| )} |
| |
| {/* Placeholder when nothing selected or selection filtered out */} |
| {(!selectedExample || !filteredExamples.includes(selectedExample)) && filteredExamples.length > 0 && ( |
| <div className="text-center py-12 bg-white rounded-lg border border-gray-200"> |
| <p className="text-gray-600">Select a source text above to view detailed vote results.</p> |
| </div> |
| )} |
| |
| {filteredExamples.length === 0 && ( |
| <div className="text-center py-12"> |
| <p className="text-gray-500">No examples available with the selected filters.</p> |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| }; |
|
|
| export default VoteResults; |