feat: EduEasy checkpoint - plan, scaffolding, pedagogia modules, components child+parent, API routes, Prisma schema, Docker infra, Better Auth
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user