TransHub / client /src /pages /VoteResults.tsx
linguabot's picture
Upload folder using huggingface_hub
0ca4a07 verified
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;
// Transform the data from backend format to frontend format
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) {
// Refresh the 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;
// Filter by search term
if (searchTerm) {
filtered = filtered.filter(sub =>
sub.transcreation.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Sort
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; // Always sort descending for better UX
});
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));
};
// Helpers to align numbering with Tutorial/Weekly pages
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);
});
// Sort each bucket by ST number if present; fallback to ObjectId time; then by title
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];
// First sort by week number
if (exampleA.sourceText.weekNumber !== exampleB.sourceText.weekNumber) {
return exampleA.sourceText.weekNumber - exampleB.sourceText.weekNumber;
}
// Then sort by category (tutorial first, then weekly-practice)
if (exampleA.sourceText.category !== exampleB.sourceText.category) {
if (exampleA.sourceText.category === 'tutorial') return -1;
if (exampleB.sourceText.category === 'tutorial') return 1;
}
// Finally, sort within each week/category bucket
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;
}
// fallback consistent ordering
return (exampleA.sourceText.title || '').localeCompare(exampleB.sourceText.title || '');
});
// If current selection is no longer visible under active filters, clear it
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;