feat: Puente Mayúscula-Minúscula + TTS SpeechSynthesis + E2E fixes
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,58 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
export interface CuisenaireRodProps {
|
||||
value: number
|
||||
color: string
|
||||
length: number
|
||||
draggable?: boolean
|
||||
onDrop?: (value: number) => void
|
||||
}
|
||||
|
||||
const rodColors: Record<number, string> = {
|
||||
1: "#FFFFFF",
|
||||
2: "#E74C3C",
|
||||
3: "#2ECC71",
|
||||
4: "#E91E63",
|
||||
5: "#F1C40F",
|
||||
6: "#1ABC9C",
|
||||
7: "#2C3E50",
|
||||
8: "#3498DB",
|
||||
9: "#9B59B6",
|
||||
10: "#E67E22",
|
||||
}
|
||||
|
||||
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) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`rounded-lg border border-border/50 shadow-sm touch-target select-none ${
|
||||
draggable ? "cursor-grab" : ""
|
||||
} ${isDragging ? "cursor-grabbing z-10" : ""}`}
|
||||
style={{
|
||||
width: `${length}px`,
|
||||
minWidth: `${length}px`,
|
||||
height: "40px",
|
||||
backgroundColor: color,
|
||||
borderColor: value === 1 ? "#ddd" : undefined,
|
||||
}}
|
||||
whileTap={draggable ? { scale: 1.08 } : undefined}
|
||||
drag={draggable}
|
||||
dragElastic={0.1}
|
||||
dragMomentum={false}
|
||||
onDragStart={() => setIsDragging(true)}
|
||||
onDragEnd={() => setIsDragging(false)}
|
||||
dragConstraints={{ left: -400, right: 400, top: -300, bottom: 300 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,53 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { GatoMascota } from "./gato-mascota"
|
||||
|
||||
const CHILD_ID_KEY = "edueasy_child_id"
|
||||
|
||||
interface Props {
|
||||
onRetry?: () => void
|
||||
}
|
||||
|
||||
export default function NoChildError({ onRetry }: Props) {
|
||||
const router = useRouter()
|
||||
const hasChildId = typeof window !== "undefined" && localStorage.getItem(CHILD_ID_KEY)
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
|
||||
<GatoMascota mood="coach" message={hasChildId ? "Ups, hubo un problema" : "Todavía no estoy conectado"} size="lg" />
|
||||
<div className="text-center max-w-xs">
|
||||
{hasChildId ? (
|
||||
<p className="text-foreground/60">
|
||||
No pude conectar con el servidor. Revisá la conexión.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-foreground/60">Pedile a mamá que te empareje desde su celular.</p>
|
||||
<p className="text-foreground/40 text-sm mt-1">Mamá va a Dashboard → Emparejar nuevo iPad</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{hasChildId && onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => router.push("/child/pairing")}
|
||||
className={`touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold ${
|
||||
hasChildId
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "bg-primary text-primary-foreground"
|
||||
}`}
|
||||
>
|
||||
{hasChildId ? "Emparejar otro iPad" : "Ir a emparejar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"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)
|
||||
const onCompleteRef = useRef(onComplete)
|
||||
onCompleteRef.current = onComplete
|
||||
|
||||
useEffect(() => {
|
||||
if (running) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(intervalRef.current!)
|
||||
onCompleteRef.current()
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
} else {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
}, [running])
|
||||
|
||||
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,158 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { getStroke } from "perfect-freehand"
|
||||
|
||||
interface TrazoLetraProps {
|
||||
letra: string
|
||||
color?: string
|
||||
onComplete?: () => void
|
||||
promptLevel?: number
|
||||
}
|
||||
|
||||
export default function TrazoLetra({
|
||||
letra,
|
||||
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) {
|
||||
drawDirectionArrow(ctx, letra)
|
||||
}
|
||||
}
|
||||
|
||||
const drawDirectionArrow = (ctx: CanvasRenderingContext2D, l: string) => {
|
||||
const w = ctx.canvas.width
|
||||
const h = ctx.canvas.height
|
||||
ctx.save()
|
||||
ctx.globalAlpha = 0.3
|
||||
ctx.strokeStyle = "#8CB8A0"
|
||||
ctx.lineWidth = 2
|
||||
ctx.fillStyle = "#8CB8A0"
|
||||
|
||||
const arrowPaths: Record<string, { sx: number; sy: number; ex: number; ey: number }> = {
|
||||
a: { sx: w * 0.3, sy: h * 0.7, ex: w * 0.7, ey: h * 0.3 },
|
||||
e: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
|
||||
i: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
|
||||
o: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.6, ey: h * 0.6 },
|
||||
u: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.5 },
|
||||
}
|
||||
|
||||
const path = arrowPaths[l.toLowerCase()]
|
||||
if (path) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(path.sx, path.sy)
|
||||
ctx.lineTo(path.ex, path.ey)
|
||||
ctx.stroke()
|
||||
|
||||
const angle = Math.atan2(path.ey - path.sy, path.ex - path.sx)
|
||||
const sz = 6
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(path.ex, path.ey)
|
||||
ctx.lineTo(path.ex - sz * Math.cos(angle - Math.PI / 6), path.ey - sz * Math.sin(angle - Math.PI / 6))
|
||||
ctx.lineTo(path.ex - sz * Math.cos(angle + Math.PI / 6), path.ey - sz * Math.sin(angle + Math.PI / 6))
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioConciencia({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const options = content.options || []
|
||||
|
||||
const handleChoose = async (opt: (typeof options)[0]) => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
onComplete(opt.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{content.text && (
|
||||
<p className="text-2xl font-display font-bold text-center">{content.text}</p>
|
||||
)}
|
||||
|
||||
{content.emoji && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="text-6xl"
|
||||
>
|
||||
{content.emoji}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-md">
|
||||
{options.map((opt, i) => (
|
||||
<motion.button
|
||||
key={`${opt.label}-${i}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
onClick={() => handleChoose(opt)}
|
||||
className="touch-target-lg rounded-2xl px-8 py-6 text-xl font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioEleccion({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const options = content.options || []
|
||||
|
||||
const handleChoose = async (opt: (typeof options)[0]) => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
onComplete(opt.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{content.text && (
|
||||
<p className="text-2xl font-display font-bold text-center">{content.text}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-sm">
|
||||
{options.map((opt, i) => (
|
||||
<motion.button
|
||||
key={`${opt.label}-${i}`}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: i * 0.12 }}
|
||||
onClick={() => handleChoose(opt)}
|
||||
className="touch-target-lg rounded-2xl px-8 py-6 text-3xl font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioLectura({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const options = content.options || []
|
||||
|
||||
const handleChoose = async (opt: (typeof options)[0]) => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
onComplete(opt.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{content.text && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="bg-card border rounded-2xl p-6 max-w-md text-center"
|
||||
>
|
||||
<p className="text-xl font-display leading-relaxed">{content.text}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{content.imageEmoji && (
|
||||
<div className="text-6xl">{content.imageEmoji}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-sm">
|
||||
{options.map((opt, i) => (
|
||||
<motion.button
|
||||
key={`${opt.label}-${i}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.12 }}
|
||||
onClick={() => handleChoose(opt)}
|
||||
className="touch-target-lg rounded-2xl px-8 py-5 text-lg font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioPalabra({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const options = content.options || []
|
||||
|
||||
const handleChoose = async (opt: (typeof options)[0]) => {
|
||||
if (opt.id === "escuchar") {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
return
|
||||
}
|
||||
onComplete(opt.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{content.imageEmoji && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="text-6xl"
|
||||
>
|
||||
{content.imageEmoji}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-md">
|
||||
{options.map((opt, i) => (
|
||||
<motion.button
|
||||
key={`${opt.label}-${i}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
onClick={() => handleChoose(opt)}
|
||||
className={`touch-target-lg rounded-2xl px-8 py-6 text-xl font-display font-bold shadow-md active:scale-95 transition-transform ${
|
||||
opt.id === "escuchar"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-white"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion, Reorder } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioSecuencia({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const sequence = content.sequence || []
|
||||
|
||||
const [items, setItems] = useState(() =>
|
||||
[...sequence].sort(() => Math.random() - 0.5),
|
||||
)
|
||||
|
||||
const handleReorder = (newOrder: string[]) => {
|
||||
setItems(newOrder)
|
||||
if (newOrder.every((item, i) => item === sequence[i])) {
|
||||
if (content.audioText) speak(content.audioText)
|
||||
onComplete(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={items}
|
||||
onReorder={handleReorder}
|
||||
className="flex flex-col gap-3 max-w-xs w-full"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Reorder.Item
|
||||
key={item}
|
||||
value={item}
|
||||
className="touch-target-lg rounded-2xl px-6 py-4 text-xl font-display font-bold bg-white shadow-md cursor-grab active:cursor-grabbing text-center"
|
||||
>
|
||||
{item}
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
|
||||
<p className="text-sm text-foreground/40">Arrastrá para ordenar</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioSensibilizacion({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const hasOptions = content.options && content.options.length > 0
|
||||
|
||||
const handleClick = async (option: { label: string; correct: boolean }) => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
onComplete(option.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-6xl text-center"
|
||||
>
|
||||
{content.emoji && <span>{content.emoji}</span>}
|
||||
</motion.div>
|
||||
|
||||
{hasOptions && (
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-sm">
|
||||
{content.options!.map((opt, i) => (
|
||||
<motion.button
|
||||
key={opt.label}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
onClick={() => handleClick(opt)}
|
||||
className="touch-target-lg rounded-2xl px-8 py-6 text-xl font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasOptions && (
|
||||
<button
|
||||
onClick={() => onComplete(true)}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
¡Lo hice!
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import TrazoLetra from "@/components/child/trazo-letra"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioTrazo({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const charToTrace = content.text && content.text.length === 1
|
||||
? content.text.toUpperCase()
|
||||
: null
|
||||
|
||||
const handleAudio = async () => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{charToTrace ? (
|
||||
<>
|
||||
<TrazoLetra
|
||||
letra={charToTrace}
|
||||
color={content.color || '#8CB8A0'}
|
||||
onComplete={() => onComplete(true)}
|
||||
promptLevel={0}
|
||||
/>
|
||||
{content.audioText && (
|
||||
<button
|
||||
onClick={handleAudio}
|
||||
className="text-sm text-primary underline"
|
||||
>
|
||||
Escuchar instrucción
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{content.emoji && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="text-6xl"
|
||||
>
|
||||
{content.emoji}
|
||||
</motion.div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onComplete(true)}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
¡Lo hice!
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import type { Exercise, ExerciseType } from "@/curriculum/types"
|
||||
|
||||
const EjercicioSensibilizacion = dynamic(() => import("./ejercicio-sensibilizacion"), { ssr: false })
|
||||
const EjercicioConciencia = dynamic(() => import("./ejercicio-conciencia"), { ssr: false })
|
||||
const EjercicioEleccion = dynamic(() => import("./ejercicio-eleccion"), { ssr: false })
|
||||
const EjercicioSecuencia = dynamic(() => import("./ejercicio-secuencia"), { ssr: false })
|
||||
const EjercicioPalabra = dynamic(() => import("./ejercicio-palabra"), { ssr: false })
|
||||
const EjercicioLectura = dynamic(() => import("./ejercicio-lectura"), { ssr: false })
|
||||
const EjercicioTrazo = dynamic(() => import("./ejercicio-trazo"), { ssr: false })
|
||||
|
||||
const componentMap: Record<ExerciseType, React.ComponentType<{ exercise: Exercise; onComplete: (correct: boolean) => void }>> = {
|
||||
sensibilizacion: EjercicioSensibilizacion,
|
||||
conciencia: EjercicioConciencia,
|
||||
trazo: EjercicioTrazo,
|
||||
eleccion: EjercicioEleccion,
|
||||
emparejar: EjercicioConciencia,
|
||||
secuencia: EjercicioSecuencia,
|
||||
palabra: EjercicioPalabra,
|
||||
problema: EjercicioLectura,
|
||||
lectura: EjercicioLectura,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function ExerciseDispatcher({ exercise, onComplete }: Props) {
|
||||
const Component = componentMap[exercise.type] || EjercicioSensibilizacion
|
||||
return <Component exercise={exercise} onComplete={onComplete} />
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
const errorLabels: Record<string, string> = {
|
||||
"discriminacion-auditiva": "Discriminación auditiva",
|
||||
"confusion-letra": "Confusión de letra",
|
||||
"silaba-dificil": "Sílaba difícil",
|
||||
"omision-letra": "Omisión de letra",
|
||||
"inversion-letra": "Inversión de letra",
|
||||
}
|
||||
|
||||
const errorColors: Record<string, string> = {
|
||||
"discriminacion-auditiva": "bg-blue-200 text-blue-800",
|
||||
"confusion-letra": "bg-red-200 text-red-800",
|
||||
"silaba-dificil": "bg-yellow-200 text-yellow-800",
|
||||
"omision-letra": "bg-purple-200 text-purple-800",
|
||||
"inversion-letra": "bg-orange-200 text-orange-800",
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: Record<string, number>
|
||||
total: number
|
||||
}
|
||||
|
||||
export default function ErrorPatternsChart({ data, total }: Props) {
|
||||
const entries = Object.entries(data)
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Sin errores registrados. ¡Bien ahí!
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map(([key, count]) => {
|
||||
const pct = Math.round((count / total) * 100)
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="font-medium">{errorLabels[key] || key}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{count} ({pct}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
key === "discriminacion-auditiva"
|
||||
? "bg-blue-400"
|
||||
: key === "confusion-letra"
|
||||
? "bg-red-400"
|
||||
: key === "silaba-dificil"
|
||||
? "bg-yellow-400"
|
||||
: key === "omision-letra"
|
||||
? "bg-purple-400"
|
||||
: "bg-orange-400"
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
subtitle?: string
|
||||
variant?: "default" | "primary" | "success" | "accent"
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variants = {
|
||||
default: "bg-white",
|
||||
primary: "bg-primary/10 border-primary/20",
|
||||
success: "bg-success/10 border-success/20",
|
||||
accent: "bg-accent/10 border-accent/20",
|
||||
}
|
||||
|
||||
export function MetricCard({ title, value, subtitle, variant = "default", className }: MetricCardProps) {
|
||||
return (
|
||||
<div className={cn("rounded-2xl p-4 shadow-sm border border-border", variants[variant], className)}>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="text-3xl font-display font-bold text-foreground mt-1">{value}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
interface MilestoneData {
|
||||
skillCode: string
|
||||
reachedAt: string
|
||||
}
|
||||
|
||||
const skillLabels: Record<string, string> = {
|
||||
"vocal-a": "Letra A",
|
||||
"vocal-e": "Letra E",
|
||||
"vocal-i": "Letra I",
|
||||
"vocal-o": "Letra O",
|
||||
"vocal-u": "Letra U",
|
||||
"numero-1": "Número 1",
|
||||
"numero-2": "Número 2",
|
||||
"numero-3": "Número 3",
|
||||
"numero-4": "Número 4",
|
||||
"numero-5": "Número 5",
|
||||
}
|
||||
|
||||
export function MilestonesList({ data }: { data: MilestoneData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-4">
|
||||
Todavía no hay logros
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.map((m) => (
|
||||
<div
|
||||
key={m.skillCode}
|
||||
className="bg-primary/10 border border-primary/20 rounded-xl px-3 py-2 text-sm font-display font-medium"
|
||||
>
|
||||
{skillLabels[m.skillCode] || m.skillCode}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
RadarChart,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
ResponsiveContainer,
|
||||
} from "recharts"
|
||||
|
||||
interface SkillData {
|
||||
skillCode: string
|
||||
stability: number
|
||||
reps: number
|
||||
}
|
||||
|
||||
export function SkillMasteryChart({ data }: { data: SkillData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-8">
|
||||
Sin skills todavía
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const chartData = data
|
||||
.filter((s) => s.reps > 0)
|
||||
.slice(0, 8)
|
||||
.map((s) => ({
|
||||
skill: s.skillCode.replace("vocal-", "").replace("numero-", "num "),
|
||||
mastery: Math.min(Math.round((s.stability / 100) * 100), 100),
|
||||
}))
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-8">
|
||||
Sin skills todavía
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<PolarGrid stroke="#E8E0D8" />
|
||||
<PolarAngleAxis dataKey="skill" tick={{ fontSize: 11, fill: "#6B7280" }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} />
|
||||
<Radar
|
||||
name="Maestría"
|
||||
dataKey="mastery"
|
||||
stroke="#8CB8A0"
|
||||
fill="#8CB8A0"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
} from "recharts"
|
||||
|
||||
interface WeeklyData {
|
||||
date: string
|
||||
minutes: number
|
||||
}
|
||||
|
||||
export function WeeklyActivityChart({ data }: { data: WeeklyData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-8">
|
||||
No hay actividad esta semana
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const daysOfWeek = ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"]
|
||||
const chartData = data.map((d) => {
|
||||
const day = new Date(d.date).getDay()
|
||||
return {
|
||||
name: daysOfWeek[day],
|
||||
minutos: d.minutes,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-full h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E8E0D8" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12, fill: "#6B7280" }} />
|
||||
<YAxis tick={{ fontSize: 12, fill: "#6B7280" }} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 12, border: "1px solid #E8E0D8" }}
|
||||
formatter={(value: number) => [`${value} min`, "Actividad"]}
|
||||
/>
|
||||
<Bar dataKey="minutos" fill="#8CB8A0" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
Reference in New Issue
Block a user