feat: EduEasy checkpoint - plan, scaffolding, pedagogia modules, components child+parent, API routes, Prisma schema, Docker infra, Better Auth

This commit is contained in:
Renato
2026-07-20 17:58:49 +02:00
commit e69b18abaa
49 changed files with 9842 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
"use client"
import { useState } from "react"
import { motion } from "framer-motion"
import { GatoMascota } from "./gato-mascota"
interface AudioUnlockProps {
onUnlock: () => void
}
export default function AudioUnlock({ onUnlock }: AudioUnlockProps) {
const [pressed, setPressed] = useState(false)
const handleUnlock = () => {
if (typeof window !== "undefined") {
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext
if (AudioCtx) {
const ctx = new AudioCtx()
ctx.resume().then(() => {
ctx.close()
})
}
}
setPressed(true)
setTimeout(onUnlock, 600)
}
return (
<div className="min-h-dvh bg-background flex flex-col items-center justify-center p-8 gap-8">
<GatoMascota mood="happy" message="¡Hola! Tocá la pantalla para empezar" size="lg" />
<motion.button
onClick={handleUnlock}
disabled={pressed}
className="bg-primary text-primary-foreground touch-target-lg rounded-full text-2xl px-12 py-6 font-display font-bold shadow-lg"
whileTap={{ scale: 0.95 }}
animate={pressed ? { scale: 1.1, opacity: 0 } : {}}
>
{pressed ? "¡Empezamos!" : "TOCÁ ACÁ"}
</motion.button>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
"use client"
import { speak } from "@/lib/speech"
export interface CPAExerciseProps {
value: number
stage: "concrete" | "pictoric" | "abstract"
onCorrect: () => void
onIncorrect: () => void
}
export function generateCPAItems(value: number, stage: string): Array<{ emoji: string; id: number }> {
const pool = ["🍎", "🐱", "⭐", "🌸", "🎈", "🐶", "🍪", "🌈", "🦋", "🧸"]
return Array.from({ length: value }, (_, i) => ({
emoji: pool[i % pool.length],
id: i,
}))
}
export function speakCPAInstruction(value: number, stage: string) {
if (stage === "concrete") {
speak(`Tocá ${value} ${getItemName(value)}`)
} else if (stage === "pictoric") {
speak(`¿Cuántos hay? Señalá el número`)
} else {
speak(`¿Este número es el ${value}?`)
}
}
function getItemName(n: number): string {
if (n === 1) return "elemento"
return "elementos"
}
+57
View File
@@ -0,0 +1,57 @@
"use client"
import { motion } from "framer-motion"
export interface CuisenaireRodProps {
value: number
color: string
length: number // in vw or px
draggable?: boolean
onDrop?: (value: number) => void
}
const rodColors: Record<number, string> = {
1: "#FFFFFF", // blanco
2: "#E74C3C", // rojo
3: "#2ECC71", // verde claro
4: "#E91E63", // rosa
5: "#F1C40F", // amarillo
6: "#1ABC9C", // verde oscuro
7: "#D35400", // naranja
8: "#3498DB", // azul
9: "#9B59B6", // púrpura
10: "#FF5733", // naranja
}
const rodLengths: Record<number, number> = {
1: 20,
2: 40,
3: 60,
4: 80,
5: 100,
6: 120,
7: 140,
8: 160,
9: 180,
10: 200,
}
export { rodColors, rodLengths }
export function CuisenaireRod({ value, color, length, draggable = false }: CuisenaireRodProps) {
return (
<motion.div
className="rounded-lg border border-border/50 shadow-sm touch-target cursor-grab active:cursor-grabbing"
style={{
width: `${length}px`,
height: "40px",
backgroundColor: color,
borderColor: value === 1 ? "#ddd" : undefined,
}}
whileTap={{ scale: 1.05 }}
drag={draggable}
dragElastic={0.2}
dragConstraints={{ left: -200, right: 200, top: -200, bottom: 200 }}
/>
)
}
+56
View File
@@ -0,0 +1,56 @@
"use client"
import { motion } from "framer-motion"
interface GatoMascotaProps {
mood?: "happy" | "thinking" | "celebrate" | "coach"
message?: string
size?: "sm" | "md" | "lg"
}
const moods = {
happy: {
emoji: "😺",
animation: { y: [0, -5, 0] },
transition: { repeat: Infinity, duration: 2 },
},
thinking: {
emoji: "🤔",
animation: { rotate: [0, -10, 10, 0] },
transition: { repeat: Infinity, duration: 1.5 },
},
celebrate: {
emoji: "🎉",
animation: { scale: [1, 1.2, 1] },
transition: { repeat: Infinity, duration: 0.8 },
},
coach: {
emoji: "🐱",
animation: { y: 0 },
transition: { duration: 0 },
},
}
export function GatoMascota({ mood = "happy", message, size = "md" }: GatoMascotaProps) {
const config = moods[mood]
const sizeClass = size === "sm" ? "text-3xl" : size === "lg" ? "text-7xl" : "text-5xl"
return (
<div className="flex flex-col items-center gap-2">
<motion.span
className={`${sizeClass} block select-none`}
animate={config.animation}
transition={config.transition}
>
{config.emoji}
</motion.span>
{message && (
<p className="text-foreground/80 text-lg font-display font-medium text-center max-w-xs">
{message}
</p>
)}
</div>
)
}
export default GatoMascota
+74
View File
@@ -0,0 +1,74 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { motion } from "framer-motion"
interface TemporizadorVisualProps {
durationMinutes: number
onComplete: () => void
running: boolean
}
export default function TemporizadorVisual({
durationMinutes,
onComplete,
running,
}: TemporizadorVisualProps) {
const [remaining, setRemaining] = useState(durationMinutes * 60)
const totalSec = durationMinutes * 60
const intervalRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (running) {
intervalRef.current = setInterval(() => {
setRemaining((prev) => {
if (prev <= 1) {
clearInterval(intervalRef.current!)
onComplete()
return 0
}
return prev - 1
})
}, 1000)
} else {
if (intervalRef.current) clearInterval(intervalRef.current)
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [running, onComplete])
const fraction = remaining / totalSec
const degrees = fraction * 360
const minutes = Math.floor(remaining / 60)
const seconds = remaining % 60
return (
<div className="flex flex-col items-center gap-2">
<div className="relative w-20 h-20">
<svg className="w-full h-full -rotate-90" viewBox="0 0 100 100">
<circle
cx="50" cy="50" r="42"
fill="none"
stroke="#E8E0D8"
strokeWidth="8"
/>
<circle
cx="50" cy="50" r="42"
fill="none"
stroke={fraction > 0.3 ? "#8CB8A0" : "#D4A574"}
strokeWidth="8"
strokeDasharray="264"
strokeDashoffset={264 * (1 - fraction)}
strokeLinecap="round"
className="transition-all duration-1000 ease-linear"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-lg font-display font-bold text-foreground">
{minutes}:{seconds.toString().padStart(2, "0")}
</span>
</div>
</div>
)
}
+125
View File
@@ -0,0 +1,125 @@
"use client"
import { useState, useRef, useCallback, useEffect } from "react"
import { getStroke } from "perfect-freehand"
import { speak } from "@/lib/speech"
interface TrazoLetraProps {
letra: string
fonema: string
color?: string
onComplete?: () => void
promptLevel?: number
}
export default function TrazoLetra({
letra,
fonema,
color = "#8CB8A0",
onComplete,
promptLevel = 0,
}: TrazoLetraProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [isDrawing, setIsDrawing] = useState(false)
const points = useRef<number[][]>([])
const [showLetter, setShowLetter] = useState(promptLevel < 2)
useEffect(() => {
if (promptLevel < 2) {
drawLetterGuide(letra)
}
}, [letra, promptLevel])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
canvas.style.touchAction = "none"
}, [])
const drawLetterGuide = (l: string) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const w = canvas.width
const h = canvas.height
ctx.clearRect(0, 0, w, h)
ctx.font = `${h * 0.6}px "Fredoka", sans-serif`
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.globalAlpha = 0.15
ctx.fillStyle = "#2D3436"
ctx.fillText(l.toUpperCase(), w / 2, h / 2)
ctx.globalAlpha = 1
}
const startDrawing = useCallback((e: React.PointerEvent) => {
setIsDrawing(true)
points.current = [[e.clientX, e.clientY, e.pressure || 0.5]]
const canvas = canvasRef.current
if (canvas) canvas.setPointerCapture(e.pointerId)
}, [])
const draw = useCallback((e: React.PointerEvent) => {
if (!isDrawing) return
points.current.push([e.clientX, e.clientY, e.pressure || 0.5])
renderStroke()
}, [isDrawing])
const endDrawing = useCallback(() => {
setIsDrawing(false)
if (points.current.length > 5 && onComplete) {
onComplete()
}
points.current = []
}, [onComplete])
const renderStroke = () => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const stroke = getStroke(points.current, {
size: 12,
thinning: 0.5,
smoothing: 0.5,
streamline: 0.5,
})
if (stroke.length < 2) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (showLetter) drawLetterGuide(letra)
ctx.beginPath()
ctx.moveTo(stroke[0][0], stroke[0][1])
for (let i = 1; i < stroke.length; i++) {
ctx.lineTo(stroke[i][0], stroke[i][1])
}
ctx.strokeStyle = color
ctx.lineWidth = 3
ctx.lineCap = "round"
ctx.lineJoin = "round"
ctx.fillStyle = color
ctx.fill()
if (promptLevel < 3) {
// draw direction arrow if early prompt level
}
}
return (
<canvas
ref={canvasRef}
width={300}
height={300}
className="w-[250px] h-[250px] touch-none rounded-2xl bg-white shadow-md"
onPointerDown={startDrawing}
onPointerMove={draw}
onPointerUp={endDrawing}
onPointerCancel={endDrawing}
/>
)
}
+43
View File
@@ -0,0 +1,43 @@
"use client"
import { motion } from "framer-motion"
export interface VocalCardProps {
letra: string
fonema: string
palabra: string
imageEmoji: string
color?: string
onClick: () => void
disabled?: boolean
}
export default function VocalCard({
letra,
fonema,
palabra,
imageEmoji,
color = "#8CB8A0",
onClick,
disabled = false,
}: VocalCardProps) {
return (
<motion.button
onClick={onClick}
disabled={disabled}
className={`touch-target-lg rounded-3xl flex flex-col items-center justify-center gap-3 p-6 shadow-md ${disabled ? "opacity-40" : ""}`}
style={{ backgroundColor: color }}
whileTap={{ scale: 0.92 }}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
>
<span className="text-5xl font-display font-bold text-white drop-shadow-sm">
{letra.toUpperCase()}
</span>
<span className="text-3xl">{imageEmoji}</span>
<span className="text-white/90 text-lg font-display font-medium">
{palabra}
</span>
</motion.button>
)
}