✨ Características: - 45 ejercicios universitarios (Basic → Advanced) - Renderizado LaTeX profesional - IA generativa (Z.ai/DashScope) - Docker 9 servicios - Tests 123/123 pasando - Seguridad enterprise (JWT, XSS, Rate limiting) 🐳 Infraestructura: - Next.js 14 + Node.js 20 - PostgreSQL 15 + Redis 7 - Docker Compose completo - Nginx + SSL ready 📚 Documentación: - 5 informes técnicos completos - README profesional - Scripts de deployment automatizados Estado: Producción lista ✅
252 lines
7.5 KiB
TypeScript
252 lines
7.5 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { MathBlock } from '@/components/math/MathFormula';
|
|
import { Lightbulb, Eye, Sparkles } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* Hint data interface
|
|
*/
|
|
export interface Hint {
|
|
id: string;
|
|
content: string;
|
|
isMath?: boolean;
|
|
cost?: number;
|
|
}
|
|
|
|
/**
|
|
* HintSystem component props
|
|
*/
|
|
export interface HintSystemProps {
|
|
/**
|
|
* Array of hints
|
|
*/
|
|
hints: Hint[];
|
|
|
|
/**
|
|
* Current points
|
|
*/
|
|
currentPoints?: number;
|
|
|
|
/**
|
|
* Points penalty per hint
|
|
*/
|
|
pointsPerHint?: number;
|
|
|
|
/**
|
|
* Revealed hint indices
|
|
*/
|
|
revealedHints?: Set<number>;
|
|
|
|
/**
|
|
* On hint reveal callback
|
|
*/
|
|
onRevealHint?: (index: number, cost: number) => void;
|
|
|
|
/**
|
|
* Additional CSS classes
|
|
*/
|
|
className?: string;
|
|
|
|
/**
|
|
* Compact mode
|
|
*/
|
|
compact?: boolean;
|
|
}
|
|
|
|
/**
|
|
* HintSystem component - displays hints with point costs
|
|
*/
|
|
export function HintSystem({
|
|
hints,
|
|
currentPoints,
|
|
pointsPerHint = 5,
|
|
revealedHints = new Set<number>(),
|
|
onRevealHint,
|
|
className,
|
|
compact = false,
|
|
}: HintSystemProps) {
|
|
const [showAll] = useState(false);
|
|
|
|
if (hints.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const canAffordHint = (hintIndex: number) => {
|
|
if (!currentPoints) return true;
|
|
|
|
const alreadyRevealed = Array.from(revealedHints).filter((i) => i <= hintIndex).length;
|
|
const cost = (alreadyRevealed + 1) * pointsPerHint;
|
|
return currentPoints >= cost;
|
|
};
|
|
|
|
const getHintCost = (hintIndex: number) => {
|
|
const alreadyRevealed = Array.from(revealedHints).filter((i) => i <= hintIndex).length;
|
|
return (alreadyRevealed + 1) * pointsPerHint;
|
|
};
|
|
|
|
const handleRevealHint = (index: number) => {
|
|
if (onRevealHint) {
|
|
onRevealHint(index, getHintCost(index));
|
|
}
|
|
};
|
|
|
|
const allRevealed = revealedHints.size === hints.length;
|
|
const hasUnrevealed = hints.length > revealedHints.size;
|
|
|
|
return (
|
|
<Card className={cn('border-yellow-200 dark:border-yellow-800', className)}>
|
|
<CardHeader className={compact ? 'pb-3' : undefined}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Lightbulb className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
|
<CardTitle className="text-lg">Hints</CardTitle>
|
|
</div>
|
|
|
|
{hasUnrevealed && !compact && (
|
|
<div className="text-sm text-muted-foreground">
|
|
{revealedHints.size}/{hints.length} revealed
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!compact && (
|
|
<CardDescription>
|
|
{currentPoints !== undefined && (
|
|
<span>Cost: {pointsPerHint} points per hint (progressive)</span>
|
|
)}
|
|
{currentPoints === undefined && <span>Click to reveal hints</span>}
|
|
</CardDescription>
|
|
)}
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-3">
|
|
{hints.map((hint, index) => {
|
|
const isRevealed = revealedHints.has(index);
|
|
const cost = getHintCost(index);
|
|
const canAfford = canAffordHint(index);
|
|
|
|
if (!showAll && !isRevealed && index > 0 && !revealedHints.has(index - 1)) {
|
|
// Hide hints that are not yet available (sequential reveal)
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={hint.id}
|
|
className={cn(
|
|
'rounded-lg border p-4 transition-all duration-300',
|
|
isRevealed
|
|
? 'bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-800'
|
|
: 'bg-muted border-muted-foreground/20',
|
|
isRevealed && 'animate-in fade-in slide-in-from-bottom-2 duration-300'
|
|
)}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div
|
|
className={cn(
|
|
'flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold',
|
|
isRevealed
|
|
? 'bg-yellow-200 dark:bg-yellow-800 text-yellow-800 dark:text-yellow-200'
|
|
: 'bg-muted-foreground/20 text-muted-foreground'
|
|
)}
|
|
>
|
|
{index + 1}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
{isRevealed ? (
|
|
<div className="animate-in fade-in duration-300 delay-100">
|
|
{hint.isMath ? (
|
|
<MathBlock formula={hint.content} />
|
|
) : (
|
|
<p className="text-sm text-yellow-900 dark:text-yellow-100">
|
|
{hint.content}
|
|
</p>
|
|
)}
|
|
|
|
{hint.cost && (
|
|
<div className="mt-2 flex items-center gap-1 text-xs text-yellow-700 dark:text-yellow-300">
|
|
<Sparkles className="h-3 w-3" />
|
|
<span>Cost: {hint.cost} points</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<p className="text-sm text-muted-foreground">
|
|
Hint #{index + 1} -{' '}
|
|
{currentPoints !== undefined ? (
|
|
canAfford ? (
|
|
<span className="text-foreground">Reveal for {cost} points</span>
|
|
) : (
|
|
<span className="text-destructive">Need {cost - currentPoints} more points</span>
|
|
)
|
|
) : (
|
|
<span className="text-foreground">Click to reveal</span>
|
|
)}
|
|
</p>
|
|
|
|
{onRevealHint && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleRevealHint(index)}
|
|
disabled={!canAfford}
|
|
className="gap-1.5"
|
|
>
|
|
<Eye className="h-3.5 w-3.5" />
|
|
Reveal Hint
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{allRevealed && (
|
|
<div className="text-center py-2 animate-in fade-in duration-300">
|
|
<p className="text-sm text-muted-foreground">
|
|
<Sparkles className="h-4 w-4 inline-block mr-1" />
|
|
All hints revealed!
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Compact hint button for inline display
|
|
*/
|
|
export interface HintButtonProps {
|
|
hintCount: number;
|
|
revealedCount: number;
|
|
onClick?: () => void;
|
|
className?: string;
|
|
}
|
|
|
|
export function HintButton({ hintCount, revealedCount, onClick, className }: HintButtonProps) {
|
|
return (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onClick}
|
|
className={cn('gap-2', className)}
|
|
>
|
|
<Lightbulb className="h-4 w-4" />
|
|
<span>Hint</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
({revealedCount}/{hintCount})
|
|
</span>
|
|
</Button>
|
|
);
|
|
}
|