Initial commit - cleaned for CV
This commit is contained in:
548
frontend/src/components/exercises/common/MatchingExercise.tsx
Normal file
548
frontend/src/components/exercises/common/MatchingExercise.tsx
Normal file
@@ -0,0 +1,548 @@
|
||||
import { useState, useCallback, DragEvent } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCcw,
|
||||
Link2,
|
||||
GripVertical,
|
||||
Trophy,
|
||||
Star,
|
||||
Target,
|
||||
Zap
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface MatchingItem {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface MatchingPair {
|
||||
leftId: string;
|
||||
rightId: string;
|
||||
}
|
||||
|
||||
export interface MatchingExerciseProps {
|
||||
leftItems: MatchingItem[];
|
||||
rightItems: MatchingItem[];
|
||||
correctPairs: MatchingPair[];
|
||||
onComplete?: (result: MatchingResult) => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
maxPoints?: number;
|
||||
shuffleItems?: boolean;
|
||||
}
|
||||
|
||||
export interface MatchingResult {
|
||||
correct: number;
|
||||
total: number;
|
||||
attempts: number;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
isPerfect: boolean;
|
||||
pairs: MatchedPairResult[];
|
||||
}
|
||||
|
||||
interface MatchedPairResult {
|
||||
leftId: string;
|
||||
rightId: string;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
interface Match {
|
||||
leftId: string;
|
||||
rightId: string;
|
||||
isCorrect?: boolean;
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle algorithm
|
||||
function shuffleArray<T>(array: T[]): T[] {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
export function MatchingExercise({
|
||||
leftItems,
|
||||
rightItems,
|
||||
correctPairs,
|
||||
onComplete,
|
||||
title = 'Emparejamiento',
|
||||
description = 'Relaciona los elementos de la columna izquierda con los de la derecha',
|
||||
maxPoints = 100,
|
||||
shuffleItems = true,
|
||||
}: MatchingExerciseProps) {
|
||||
// Initialize items with optional shuffle
|
||||
const [displayLeftItems, setDisplayLeftItems] = useState<MatchingItem[]>(() =>
|
||||
shuffleItems ? shuffleArray(leftItems) : leftItems
|
||||
);
|
||||
const [displayRightItems, setDisplayRightItems] = useState<MatchingItem[]>(() =>
|
||||
shuffleItems ? shuffleArray(rightItems) : rightItems
|
||||
);
|
||||
|
||||
const [matches, setMatches] = useState<Match[]>([]);
|
||||
const [selectedLeft, setSelectedLeft] = useState<string | null>(null);
|
||||
const [selectedRight, setSelectedRight] = useState<string | null>(null);
|
||||
const [attempts, setAttempts] = useState(0);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [dragOverLeft, setDragOverLeft] = useState<string | null>(null);
|
||||
const [dragOverRight, setDragOverRight] = useState<string | null>(null);
|
||||
|
||||
// Check if all pairs are matched
|
||||
const allMatched = matches.length === leftItems.length;
|
||||
|
||||
// Calculate score based on attempts
|
||||
const calculateScore = useCallback((correctCount: number, totalAttempts: number): number => {
|
||||
if (correctCount === 0) return 0;
|
||||
|
||||
// Base score per correct match
|
||||
const baseScore = maxPoints / leftItems.length;
|
||||
|
||||
// Penalty for attempts (starts at 100%, decreases with each attempt)
|
||||
const efficiency = Math.max(0.5, 1 - (totalAttempts - leftItems.length) * 0.05);
|
||||
|
||||
return Math.round(correctCount * baseScore * efficiency);
|
||||
}, [maxPoints, leftItems.length]);
|
||||
|
||||
// Handle left item click
|
||||
const handleLeftClick = (itemId: string) => {
|
||||
if (showResults) return;
|
||||
|
||||
if (selectedLeft === itemId) {
|
||||
setSelectedLeft(null);
|
||||
} else {
|
||||
setSelectedLeft(itemId);
|
||||
// If right is already selected, create match
|
||||
if (selectedRight) {
|
||||
handleCreateMatch(itemId, selectedRight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle right item click
|
||||
const handleRightClick = (itemId: string) => {
|
||||
if (showResults) return;
|
||||
|
||||
if (selectedRight === itemId) {
|
||||
setSelectedRight(null);
|
||||
} else {
|
||||
setSelectedRight(itemId);
|
||||
// If left is already selected, create match
|
||||
if (selectedLeft) {
|
||||
handleCreateMatch(selectedLeft, itemId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new match
|
||||
const handleCreateMatch = (leftId: string, rightId: string) => {
|
||||
// Check if either item is already matched
|
||||
const isLeftMatched = matches.some(m => m.leftId === leftId);
|
||||
const isRightMatched = matches.some(m => m.rightId === rightId);
|
||||
|
||||
if (isLeftMatched || isRightMatched) return;
|
||||
|
||||
setMatches(prev => [...prev, { leftId, rightId }]);
|
||||
setSelectedLeft(null);
|
||||
setSelectedRight(null);
|
||||
setAttempts(prev => prev + 1);
|
||||
};
|
||||
|
||||
// Handle drag start
|
||||
const handleDragStart = (e: DragEvent<HTMLDivElement>, itemId: string, side: 'left' | 'right') => {
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ itemId, side }));
|
||||
e.dataTransfer.effectAllowed = 'link';
|
||||
};
|
||||
|
||||
// Handle drag over
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>, itemId: string, side: 'left' | 'right') => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
if (side === 'left') {
|
||||
setDragOverLeft(itemId);
|
||||
} else {
|
||||
setDragOverRight(itemId);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drag leave
|
||||
const handleDragLeave = (side: 'left' | 'right') => {
|
||||
if (side === 'left') {
|
||||
setDragOverLeft(null);
|
||||
} else {
|
||||
setDragOverRight(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drop
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>, targetId: string, targetSide: 'left' | 'right') => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
|
||||
const { itemId: draggedId, side: draggedSide } = data;
|
||||
|
||||
// Prevent dropping on same side
|
||||
if (draggedSide === targetSide) return;
|
||||
|
||||
// Create match
|
||||
if (draggedSide === 'left' && targetSide === 'right') {
|
||||
handleCreateMatch(draggedId, targetId);
|
||||
} else if (draggedSide === 'right' && targetSide === 'left') {
|
||||
handleCreateMatch(targetId, draggedId);
|
||||
}
|
||||
} catch {
|
||||
// Invalid drag data
|
||||
}
|
||||
|
||||
setDragOverLeft(null);
|
||||
setDragOverRight(null);
|
||||
};
|
||||
|
||||
// Remove a match
|
||||
const handleRemoveMatch = (leftId: string) => {
|
||||
if (showResults) return;
|
||||
setMatches(prev => prev.filter(m => m.leftId !== leftId));
|
||||
};
|
||||
|
||||
// Validate all matches
|
||||
const handleValidate = () => {
|
||||
const validatedMatches = matches.map(match => {
|
||||
const isCorrect = correctPairs.some(
|
||||
p => p.leftId === match.leftId && p.rightId === match.rightId
|
||||
);
|
||||
return { ...match, isCorrect, checked: true };
|
||||
});
|
||||
|
||||
setMatches(validatedMatches);
|
||||
setShowResults(true);
|
||||
|
||||
const correctCount = validatedMatches.filter(m => m.isCorrect).length;
|
||||
const score = calculateScore(correctCount, attempts);
|
||||
|
||||
const result: MatchingResult = {
|
||||
correct: correctCount,
|
||||
total: leftItems.length,
|
||||
attempts,
|
||||
score,
|
||||
maxScore: maxPoints,
|
||||
isPerfect: correctCount === leftItems.length,
|
||||
pairs: validatedMatches.map(m => ({
|
||||
leftId: m.leftId,
|
||||
rightId: m.rightId,
|
||||
isCorrect: m.isCorrect || false,
|
||||
})),
|
||||
};
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(result);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset exercise
|
||||
const handleReset = () => {
|
||||
setDisplayLeftItems(shuffleItems ? shuffleArray(leftItems) : leftItems);
|
||||
setDisplayRightItems(shuffleItems ? shuffleArray(rightItems) : rightItems);
|
||||
setMatches([]);
|
||||
setSelectedLeft(null);
|
||||
setSelectedRight(null);
|
||||
setAttempts(0);
|
||||
setShowResults(false);
|
||||
setDragOverLeft(null);
|
||||
setDragOverRight(null);
|
||||
};
|
||||
|
||||
// Get matched item for display
|
||||
const getMatchedRightItem = (leftId: string) => {
|
||||
const match = matches.find(m => m.leftId === leftId);
|
||||
if (!match) return null;
|
||||
return rightItems.find(item => item.id === match.rightId);
|
||||
};
|
||||
|
||||
const getMatchedLeftItem = (rightId: string) => {
|
||||
const match = matches.find(m => m.rightId === rightId);
|
||||
if (!match) return null;
|
||||
return leftItems.find(item => item.id === match.leftId);
|
||||
};
|
||||
|
||||
// Check if item is matched
|
||||
const isLeftMatched = (id: string) => matches.some(m => m.leftId === id);
|
||||
const isRightMatched = (id: string) => matches.some(m => m.rightId === id);
|
||||
|
||||
// Get match status
|
||||
const getMatchStatus = (leftId: string): 'correct' | 'incorrect' | 'unchecked' | null => {
|
||||
const match = matches.find(m => m.leftId === leftId);
|
||||
if (!match || !showResults) return null;
|
||||
return match.isCorrect ? 'correct' : 'incorrect';
|
||||
};
|
||||
|
||||
// Calculate results
|
||||
const correctCount = matches.filter(m => m.isCorrect).length;
|
||||
const score = calculateScore(correctCount, attempts);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">{title}</h3>
|
||||
<p className="text-gray-600 mt-1">{description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 px-3 py-1.5 rounded-full">
|
||||
<Target size={16} />
|
||||
<span className="font-semibold text-sm">{maxPoints} pts</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-blue-600 bg-blue-50 px-3 py-1.5 rounded-full">
|
||||
<Zap size={16} />
|
||||
<span className="font-semibold text-sm">{attempts} {attempts === 1 ? 'intento' : 'intentos'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Matching Area */}
|
||||
<Card className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">
|
||||
Columna A
|
||||
</h4>
|
||||
{displayLeftItems.map(item => {
|
||||
const matchedItem = getMatchedRightItem(item.id);
|
||||
const status = getMatchStatus(item.id);
|
||||
const isSelected = selectedLeft === item.id;
|
||||
const isMatched = isLeftMatched(item.id);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
draggable={!showResults && !isMatched}
|
||||
onDragStart={(e) => handleDragStart(e as unknown as DragEvent<HTMLDivElement>, item.id, 'left')}
|
||||
onDragOver={(e) => !showResults && handleDragOver(e as unknown as DragEvent<HTMLDivElement>, item.id, 'left')}
|
||||
onDragLeave={() => handleDragLeave('left')}
|
||||
onDrop={(e) => handleDrop(e as unknown as DragEvent<HTMLDivElement>, item.id, 'left')}
|
||||
onClick={() => handleLeftClick(item.id)}
|
||||
whileHover={!showResults && !isMatched ? { scale: 1.02 } : {}}
|
||||
whileTap={!showResults && !isMatched ? { scale: 0.98 } : {}}
|
||||
className={`
|
||||
relative p-4 rounded-lg border-2 transition-all cursor-pointer
|
||||
${!showResults && !isMatched ? 'hover:border-blue-300 hover:shadow-md' : ''}
|
||||
${isSelected ? 'border-blue-500 bg-blue-50' : ''}
|
||||
${isMatched && !showResults ? 'border-green-300 bg-green-50' : ''}
|
||||
${status === 'correct' ? 'border-green-500 bg-green-50' : ''}
|
||||
${status === 'incorrect' ? 'border-red-500 bg-red-50' : ''}
|
||||
${dragOverLeft === item.id ? 'border-blue-400 bg-blue-100' : ''}
|
||||
${!isMatched && !isSelected ? 'border-gray-200' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical size={16} className="text-gray-400 flex-shrink-0" />
|
||||
<span className="font-medium text-gray-800">{item.content}</span>
|
||||
</div>
|
||||
|
||||
{/* Match indicator */}
|
||||
{matchedItem && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link2 size={14} className="text-gray-400" />
|
||||
<span className="text-gray-600">{matchedItem.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status icons */}
|
||||
{showResults && status && (
|
||||
<div className="absolute top-2 right-2">
|
||||
{status === 'correct' ? (
|
||||
<CheckCircle size={20} className="text-green-600" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remove button */}
|
||||
{isMatched && !showResults && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveMatch(item.id);
|
||||
}}
|
||||
className="absolute top-2 right-2 p-1 hover:bg-gray-200 rounded-full transition-colors"
|
||||
>
|
||||
<XCircle size={16} className="text-gray-400 hover:text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">
|
||||
Columna B
|
||||
</h4>
|
||||
{displayRightItems.map(item => {
|
||||
const matchedItem = getMatchedLeftItem(item.id);
|
||||
const isSelected = selectedRight === item.id;
|
||||
const isMatched = isRightMatched(item.id);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
draggable={!showResults && !isMatched}
|
||||
onDragStart={(e) => handleDragStart(e as unknown as DragEvent<HTMLDivElement>, item.id, 'right')}
|
||||
onDragOver={(e) => !showResults && handleDragOver(e as unknown as DragEvent<HTMLDivElement>, item.id, 'right')}
|
||||
onDragLeave={() => handleDragLeave('right')}
|
||||
onDrop={(e) => handleDrop(e as unknown as DragEvent<HTMLDivElement>, item.id, 'right')}
|
||||
onClick={() => handleRightClick(item.id)}
|
||||
whileHover={!showResults && !isMatched ? { scale: 1.02 } : {}}
|
||||
whileTap={!showResults && !isMatched ? { scale: 0.98 } : {}}
|
||||
className={`
|
||||
relative p-4 rounded-lg border-2 transition-all cursor-pointer
|
||||
${!showResults && !isMatched ? 'hover:border-blue-300 hover:shadow-md' : ''}
|
||||
${isSelected ? 'border-blue-500 bg-blue-50' : ''}
|
||||
${isMatched && !showResults ? 'border-green-300 bg-green-50' : ''}
|
||||
${dragOverRight === item.id ? 'border-blue-400 bg-blue-100' : ''}
|
||||
${!isMatched && !isSelected ? 'border-gray-200' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical size={16} className="text-gray-400 flex-shrink-0" />
|
||||
<span className="font-medium text-gray-800">{item.content}</span>
|
||||
</div>
|
||||
|
||||
{/* Match indicator */}
|
||||
{matchedItem && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link2 size={14} className="text-gray-400" />
|
||||
<span className="text-gray-600">{matchedItem.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Results Section */}
|
||||
<AnimatePresence>
|
||||
{showResults && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<Card className="p-6 bg-gradient-to-br from-blue-50 to-indigo-50 border-blue-200">
|
||||
<div className="text-center mb-6">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, delay: 0.2 }}
|
||||
className={`inline-flex items-center justify-center w-20 h-20 rounded-full mb-4 ${
|
||||
correctCount === leftItems.length
|
||||
? 'bg-gradient-to-br from-yellow-400 to-orange-500'
|
||||
: 'bg-gradient-to-br from-blue-400 to-indigo-500'
|
||||
}`}
|
||||
>
|
||||
<Trophy size={40} className="text-white" />
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{correctCount === leftItems.length
|
||||
? '¡Perfecto!'
|
||||
: correctCount >= leftItems.length * 0.7
|
||||
? '¡Muy bien!'
|
||||
: '¡Sigue practicando!'}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600">
|
||||
{correctCount} de {leftItems.length} emparejamientos correctos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Score Display */}
|
||||
<div className="grid grid-cols-3 gap-4 max-w-lg mx-auto mb-6">
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<Star className="w-6 h-6 text-blue-500 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-blue-700">{score}</p>
|
||||
<p className="text-sm text-blue-600">Puntuación</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<Target className="w-6 h-6 text-green-500 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-green-700">{maxPoints}</p>
|
||||
<p className="text-sm text-green-600">Máximo</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<Zap className="w-6 h-6 text-purple-500 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-purple-700">
|
||||
{Math.round((score / maxPoints) * 100)}%
|
||||
</p>
|
||||
<p className="text-sm text-purple-600">Precisión</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RefreshCcw size={16} className="mr-2" />
|
||||
Reiniciar
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!showResults ? (
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
disabled={!allMatched}
|
||||
>
|
||||
Validar Emparejamientos
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleReset}>
|
||||
Intentar de Nuevo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
{!allMatched && matches.length > 0 && !showResults && (
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p>
|
||||
Arrastra elementos o haz clic para conectar. Tienes{' '}
|
||||
<span className="font-semibold text-blue-600">{leftItems.length - matches.length}</span>{' '}
|
||||
emparejamientos pendientes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allMatched && !showResults && (
|
||||
<div className="text-center text-sm text-green-600">
|
||||
<p className="font-medium">Todos los elementos están emparejados. ¡Valida tu respuesta!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MatchingExercise;
|
||||
Reference in New Issue
Block a user