Initial commit - cleaned for CV

This commit is contained in:
Renato97
2026-03-31 01:28:28 -03:00
commit ce9f0d5180
203 changed files with 50950 additions and 0 deletions

View 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;