feat: implement MathTree MVP — skill tree, workbench, and progress system
- Interactive skill tree with dagre auto-layout and React Flow (15 math+logic nodes) - Workbench with math input (LTR/RTL toggle), multiple choice, keyboard shortcuts - Challenge verification engine with retry-based flow (no answer reveal until 3 failures) - Scratchpad canvas with freehand drawing, text boxes, eraser, colors, and stroke sizes - "Aprende primero" collapsible explanations on introductory challenges - XP/level system, daily streaks, 7 achievements, progress persistence via Zustand - Profile page with stats and achievement gallery - Sidebar navigation with XP bar and streak badge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
16
package-lock.json
generated
16
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -514,6 +515,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@dagrejs/dagre": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz",
|
||||
"integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dagrejs/graphlib": "4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@dagrejs/graphlib": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz",
|
||||
"integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "1.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.57.2.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
64
src/app/(main)/layout.tsx
Normal file
64
src/app/(main)/layout.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { TreePine, Wrench, User, Trophy } from 'lucide-react';
|
||||
import { XPBar } from '@/components/common/XPBar';
|
||||
import { StreakBadge } from '@/components/common/StreakBadge';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/skill-tree', label: 'Árbol', icon: TreePine },
|
||||
{ href: '/profile', label: 'Perfil', icon: User },
|
||||
];
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-16 md:w-56 shrink-0 border-r border-border bg-card flex flex-col">
|
||||
<div className="p-3 md:p-4 border-b border-border">
|
||||
<Link href="/skill-tree" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center text-primary-foreground font-bold text-lg">
|
||||
M
|
||||
</div>
|
||||
<span className="hidden md:block font-bold text-lg">MathTree</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg mb-1 transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="w-5 h-5 shrink-0" />
|
||||
<span className="hidden md:block text-sm">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-border space-y-3">
|
||||
<div className="hidden md:block">
|
||||
<XPBar />
|
||||
</div>
|
||||
<div className="flex justify-center md:justify-start">
|
||||
<StreakBadge />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
src/app/(main)/profile/page.tsx
Normal file
132
src/app/(main)/profile/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useProgressStore } from '@/stores/useProgressStore';
|
||||
import { xpProgressInLevel } from '@/types/user';
|
||||
import { skillNodes } from '@/data/skill-tree';
|
||||
import { allChallenges } from '@/data/challenges/math';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Trophy, Flame, Target, Star, BookOpen, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const achievementMeta: Record<string, { title: string; icon: string; description: string }> = {
|
||||
'first-step': { title: 'Primer Paso', icon: '🎯', description: 'Completa tu primer reto' },
|
||||
'getting-started': { title: 'En Marcha', icon: '🚀', description: 'Completa 10 retos' },
|
||||
'explorer': { title: 'Explorador', icon: '🗺️', description: 'Completa 3 nodos del árbol' },
|
||||
'scholar': { title: 'Erudito', icon: '📚', description: 'Completa 5 nodos del árbol' },
|
||||
'consistent': { title: 'Constante', icon: '🔥', description: 'Racha de 3 días' },
|
||||
'dedicated': { title: 'Dedicado', icon: '💪', description: 'Racha de 7 días' },
|
||||
'polymath': { title: 'Polímata', icon: '🧠', description: 'Completa nodos de 3 ramas distintas' },
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
const progress = useProgressStore();
|
||||
const { current, needed } = xpProgressInLevel(progress.totalXP);
|
||||
const completedChallengeCount = Object.keys(progress.completedChallenges).length;
|
||||
const completedNodeIds = progress.getCompletedNodeIds();
|
||||
const totalNodes = skillNodes.length;
|
||||
const totalChallenges = allChallenges.length;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<header className="px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
||||
<h1 className="text-xl font-bold">Tu Perfil</h1>
|
||||
</header>
|
||||
|
||||
<div className="p-4 md:p-8 max-w-4xl mx-auto space-y-6">
|
||||
{/* Level & XP */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/20 flex items-center justify-center text-2xl font-bold text-primary">
|
||||
{progress.level}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-bold">Nivel {progress.level}</h2>
|
||||
<p className="text-sm text-muted-foreground">{progress.totalXP} XP total</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Progress value={(current / needed) * 100} className="flex-1 h-3" />
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{current}/{needed} XP
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4 text-center">
|
||||
<Target className="w-6 h-6 mx-auto mb-2 text-primary" />
|
||||
<div className="text-2xl font-bold">{completedChallengeCount}</div>
|
||||
<div className="text-xs text-muted-foreground">de {totalChallenges} retos</div>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<BookOpen className="w-6 h-6 mx-auto mb-2 text-green-500" />
|
||||
<div className="text-2xl font-bold">{completedNodeIds.length}</div>
|
||||
<div className="text-xs text-muted-foreground">de {totalNodes} nodos</div>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<Flame className="w-6 h-6 mx-auto mb-2 text-orange-500" />
|
||||
<div className="text-2xl font-bold">{progress.currentStreak}</div>
|
||||
<div className="text-xs text-muted-foreground">racha actual</div>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<Star className="w-6 h-6 mx-auto mb-2 text-amber-500" />
|
||||
<div className="text-2xl font-bold">{progress.longestStreak}</div>
|
||||
<div className="text-xs text-muted-foreground">mejor racha</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Achievements */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Trophy className="w-5 h-5 text-amber-500" />
|
||||
<h2 className="text-lg font-bold">Logros</h2>
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
{progress.achievements.length}/{Object.keys(achievementMeta).length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{Object.entries(achievementMeta).map(([id, meta]) => {
|
||||
const unlocked = progress.achievements.includes(id);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border ${
|
||||
unlocked
|
||||
? 'bg-amber-500/5 border-amber-500/20'
|
||||
: 'bg-muted/30 border-border opacity-50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{meta.icon}</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{meta.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{meta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Reset */}
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm('¿Estás seguro? Se borrará todo tu progreso.')) {
|
||||
progress.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
Resetear progreso
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/app/(main)/skill-tree/page.tsx
Normal file
19
src/app/(main)/skill-tree/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { SkillTreeCanvas } from '@/components/skill-tree/SkillTreeCanvas';
|
||||
|
||||
export default function SkillTreePage() {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<header className="px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
||||
<h1 className="text-xl font-bold">Árbol de Habilidades</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Haz clic en un nodo para ver sus retos
|
||||
</p>
|
||||
</header>
|
||||
<div className="flex-1">
|
||||
<SkillTreeCanvas />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/app/(main)/workbench/[challengeId]/page.tsx
Normal file
21
src/app/(main)/workbench/[challengeId]/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getChallengeById } from '@/data/challenges/math';
|
||||
import { WorkbenchShell } from '@/components/workbench/WorkbenchShell';
|
||||
|
||||
export default function WorkbenchPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ challengeId: string }>;
|
||||
}) {
|
||||
const { challengeId } = use(params);
|
||||
const challenge = getChallengeById(decodeURIComponent(challengeId));
|
||||
|
||||
if (!challenge) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <WorkbenchShell challenge={challenge} />;
|
||||
}
|
||||
@@ -127,4 +127,69 @@
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* React Flow dark mode overrides */
|
||||
.react-flow__controls {
|
||||
background: var(--card) !important;
|
||||
border-color: var(--border) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.react-flow__controls button {
|
||||
background: var(--card) !important;
|
||||
border-color: var(--border) !important;
|
||||
fill: var(--foreground) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.react-flow__controls button:hover {
|
||||
background: var(--accent) !important;
|
||||
}
|
||||
|
||||
.react-flow__controls button svg {
|
||||
fill: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.react-flow__edge-path {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.react-flow__attribution {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Shake animation for wrong answers */
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
15% { transform: translateX(-6px); }
|
||||
30% { transform: translateX(6px); }
|
||||
45% { transform: translateX(-4px); }
|
||||
60% { transform: translateX(4px); }
|
||||
75% { transform: translateX(-2px); }
|
||||
90% { transform: translateX(2px); }
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
/* Ensure handles are visible and edges connect properly */
|
||||
.react-flow__handle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.react-flow__node:hover .react-flow__handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Fix: global border rule was breaking react-flow edge rendering */
|
||||
.react-flow__edges,
|
||||
.react-flow__edge,
|
||||
.react-flow__edge-path,
|
||||
.react-flow__connection,
|
||||
.react-flow__handle {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "MathTree — Aprende desde lo básico hasta lo extraordinario",
|
||||
description: "Plataforma de aprendizaje interactivo con árbol de habilidades interdisciplinario",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,10 +25,12 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
lang="es"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased dark`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,65 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
redirect('/skill-tree');
|
||||
}
|
||||
|
||||
17
src/components/common/StreakBadge.tsx
Normal file
17
src/components/common/StreakBadge.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useProgressStore } from '@/stores/useProgressStore';
|
||||
import { Flame } from 'lucide-react';
|
||||
|
||||
export function StreakBadge() {
|
||||
const { currentStreak } = useProgressStore();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Flame className={`w-4 h-4 ${currentStreak > 0 ? 'text-orange-500' : 'text-muted-foreground'}`} />
|
||||
<span className={currentStreak > 0 ? 'text-orange-500 font-medium' : 'text-muted-foreground'}>
|
||||
{currentStreak}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/components/common/XPBar.tsx
Normal file
26
src/components/common/XPBar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useProgressStore } from '@/stores/useProgressStore';
|
||||
import { xpProgressInLevel } from '@/types/user';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
export function XPBar() {
|
||||
const { totalXP, level } = useProgressStore();
|
||||
const { current, needed } = xpProgressInLevel(totalXP);
|
||||
const percent = Math.round((current / needed) * 100);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-sm font-bold text-primary">
|
||||
{level}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="font-medium text-foreground">{totalXP} XP</div>
|
||||
<div>{current}/{needed}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={percent} className="w-24 h-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/skill-tree/SkillNode.tsx
Normal file
54
src/components/skill-tree/SkillNode.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
|
||||
import { Lock } from 'lucide-react';
|
||||
import type { SkillNode, NodeStatus } from '@/types/skill-tree';
|
||||
|
||||
type SkillNodeData = SkillNode & { status: NodeStatus } & Record<string, unknown>;
|
||||
type SkillNodeType = Node<SkillNodeData>;
|
||||
|
||||
const statusStyles: Record<NodeStatus, string> = {
|
||||
locked: 'bg-muted/50 border-muted-foreground/20 opacity-50 cursor-not-allowed',
|
||||
available: 'bg-card border-primary/50 hover:border-primary hover:shadow-lg hover:shadow-primary/10 cursor-pointer',
|
||||
'in-progress': 'bg-card border-amber-500/50 shadow-md shadow-amber-500/10 cursor-pointer',
|
||||
completed: 'bg-card border-green-500/50 shadow-md shadow-green-500/10 cursor-pointer',
|
||||
mastered: 'bg-card border-purple-500/50 shadow-md shadow-purple-500/10 cursor-pointer',
|
||||
};
|
||||
|
||||
const statusBadge: Record<NodeStatus, { label: string; className: string } | null> = {
|
||||
locked: null,
|
||||
available: { label: 'Disponible', className: 'bg-primary/20 text-primary' },
|
||||
'in-progress': { label: 'En progreso', className: 'bg-amber-500/20 text-amber-500' },
|
||||
completed: { label: 'Completado', className: 'bg-green-500/20 text-green-500' },
|
||||
mastered: { label: 'Dominado', className: 'bg-purple-500/20 text-purple-500' },
|
||||
};
|
||||
|
||||
export const SkillNodeComponent = memo(function SkillNodeComponent({
|
||||
data,
|
||||
}: NodeProps<SkillNodeType>) {
|
||||
const status = data.status;
|
||||
const badge = statusBadge[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-4 py-3 rounded-xl border-2 transition-all duration-200 min-w-[140px] max-w-[180px] ${statusStyles[status]}`}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} className="!bg-muted-foreground/30 !w-2 !h-2 !border-0" />
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{status === 'locked' ? '' : data.icon}</span>
|
||||
{status === 'locked' && <Lock className="w-4 h-4 text-muted-foreground" />}
|
||||
<span className="text-sm font-semibold truncate">{data.title}</span>
|
||||
</div>
|
||||
|
||||
{badge && (
|
||||
<div className={`text-[10px] font-medium px-2 py-0.5 rounded-full w-fit ${badge.className}`}>
|
||||
{badge.label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-muted-foreground/30 !w-2 !h-2 !border-0" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
135
src/components/skill-tree/SkillNodeDetail.tsx
Normal file
135
src/components/skill-tree/SkillNodeDetail.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { X, Star, CheckCircle2, Lock, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { getNodeById } from '@/data/skill-tree';
|
||||
import { getChallengesForNode } from '@/data/challenges/math';
|
||||
import { useProgressStore } from '@/stores/useProgressStore';
|
||||
|
||||
interface SkillNodeDetailProps {
|
||||
nodeId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SkillNodeDetail({ nodeId, onClose }: SkillNodeDetailProps) {
|
||||
const router = useRouter();
|
||||
const node = getNodeById(nodeId);
|
||||
const getNodeStatus = useProgressStore((s) => s.getNodeStatus);
|
||||
const completedChallenges = useProgressStore((s) => s.completedChallenges);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const status = getNodeStatus(nodeId);
|
||||
const challenges = getChallengesForNode(nodeId);
|
||||
const completedCount = challenges.filter((c) => completedChallenges[c.id]).length;
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 right-0 h-full w-full sm:w-96 bg-card border-l border-border shadow-2xl z-50 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-border flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-2xl">{node.icon}</span>
|
||||
<h2 className="text-lg font-bold">{node.title}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{node.description}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{node.discipline}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-3 h-3 ${
|
||||
i < node.difficulty ? 'text-amber-500 fill-amber-500' : 'text-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="px-4 py-3 bg-muted/30 border-b border-border">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Progreso</span>
|
||||
<span className="font-medium">
|
||||
{completedCount}/{challenges.length} retos
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-muted rounded-full mt-2">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-500"
|
||||
style={{ width: `${challenges.length > 0 ? (completedCount / challenges.length) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Challenges list */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-2">
|
||||
{status === 'locked' ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Lock className="w-12 h-12 mb-3 opacity-50" />
|
||||
<p className="text-sm font-medium">Nodo bloqueado</p>
|
||||
<p className="text-xs mt-1 text-center">
|
||||
Completa los prerrequisitos para desbloquear
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
challenges.map((challenge, index) => {
|
||||
const isCompleted = !!completedChallenges[challenge.id];
|
||||
return (
|
||||
<Card
|
||||
key={challenge.id}
|
||||
className={`p-3 cursor-pointer transition-all hover:shadow-md ${
|
||||
isCompleted ? 'bg-green-500/5 border-green-500/20' : 'hover:border-primary/30'
|
||||
}`}
|
||||
onClick={() => {
|
||||
router.push(`/workbench/${encodeURIComponent(challenge.id)}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||
isCompleted
|
||||
? 'bg-green-500/20 text-green-500'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{challenge.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+{challenge.xpReward} XP
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isCompleted && (
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
src/components/skill-tree/SkillTreeCanvas.tsx
Normal file
154
src/components/skill-tree/SkillTreeCanvas.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
Node,
|
||||
Edge,
|
||||
BackgroundVariant,
|
||||
Position,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import Dagre from '@dagrejs/dagre';
|
||||
import { skillNodes, skillEdges } from '@/data/skill-tree';
|
||||
import { useProgressStore } from '@/stores/useProgressStore';
|
||||
import { SkillNodeComponent } from './SkillNode';
|
||||
import { SkillNodeDetail } from './SkillNodeDetail';
|
||||
|
||||
const nodeTypes = {
|
||||
skillNode: SkillNodeComponent,
|
||||
};
|
||||
|
||||
const NODE_WIDTH = 170;
|
||||
const NODE_HEIGHT = 70;
|
||||
|
||||
function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
||||
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
g.setGraph({
|
||||
rankdir: 'TB', // top to bottom
|
||||
nodesep: 80, // horizontal spacing between nodes
|
||||
ranksep: 120, // vertical spacing between ranks
|
||||
edgesep: 40, // minimum spacing between edges
|
||||
marginx: 40,
|
||||
marginy: 40,
|
||||
});
|
||||
|
||||
nodes.forEach((node) => {
|
||||
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
Dagre.layout(g);
|
||||
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
const nodeWithPosition = g.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - NODE_WIDTH / 2,
|
||||
y: nodeWithPosition.y - NODE_HEIGHT / 2,
|
||||
},
|
||||
sourcePosition: Position.Bottom,
|
||||
targetPosition: Position.Top,
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes: layoutedNodes, edges };
|
||||
}
|
||||
|
||||
export function SkillTreeCanvas() {
|
||||
const getNodeStatus = useProgressStore((s) => s.getNodeStatus);
|
||||
const completedChallenges = useProgressStore((s) => s.completedChallenges);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
const rawNodes: Node[] = skillNodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: 'skillNode',
|
||||
position: { x: 0, y: 0 }, // dagre will override
|
||||
draggable: false,
|
||||
data: {
|
||||
...node,
|
||||
status: getNodeStatus(node.id),
|
||||
},
|
||||
}));
|
||||
|
||||
const rawEdges: Edge[] = skillEdges.map((edge) => {
|
||||
const targetStatus = getNodeStatus(edge.to);
|
||||
const sourceStatus = getNodeStatus(edge.from);
|
||||
let strokeColor = '#444';
|
||||
if (sourceStatus === 'completed' && targetStatus !== 'locked') {
|
||||
strokeColor = '#22c55e';
|
||||
} else if (targetStatus === 'available') {
|
||||
strokeColor = '#6366f1';
|
||||
} else if (targetStatus !== 'locked') {
|
||||
strokeColor = '#777';
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${edge.from}->${edge.to}`,
|
||||
source: edge.from,
|
||||
target: edge.to,
|
||||
type: 'bezier',
|
||||
animated: targetStatus === 'available',
|
||||
style: {
|
||||
stroke: strokeColor,
|
||||
strokeWidth: 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return getLayoutedElements(rawNodes, rawEdges);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [getNodeStatus, completedChallenges]);
|
||||
|
||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
setSelectedNodeId(node.id);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
Cargando árbol...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodeClick={onNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.4 }}
|
||||
minZoom={0.3}
|
||||
maxZoom={1.5}
|
||||
nodesDraggable={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="hsl(var(--muted-foreground) / 0.15)" />
|
||||
<Controls />
|
||||
</ReactFlow>
|
||||
|
||||
{selectedNodeId && (
|
||||
<SkillNodeDetail
|
||||
nodeId={selectedNodeId}
|
||||
onClose={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
src/components/ui/badge.tsx
Normal file
52
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
103
src/components/ui/card.tsx
Normal file
103
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
160
src/components/ui/dialog.tsx
Normal file
160
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
83
src/components/ui/progress.tsx
Normal file
83
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
children,
|
||||
value,
|
||||
...props
|
||||
}: ProgressPrimitive.Root.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
value={value}
|
||||
data-slot="progress"
|
||||
className={cn("flex flex-wrap gap-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ProgressTrack>
|
||||
<ProgressIndicator />
|
||||
</ProgressTrack>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Track
|
||||
className={cn(
|
||||
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
data-slot="progress-track"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressIndicator({
|
||||
className,
|
||||
...props
|
||||
}: ProgressPrimitive.Indicator.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn("h-full bg-primary transition-all", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Label
|
||||
className={cn("text-sm font-medium", className)}
|
||||
data-slot="progress-label"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Value
|
||||
className={cn(
|
||||
"ml-auto text-sm text-muted-foreground tabular-nums",
|
||||
className
|
||||
)}
|
||||
data-slot="progress-value"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Progress,
|
||||
ProgressTrack,
|
||||
ProgressIndicator,
|
||||
ProgressLabel,
|
||||
ProgressValue,
|
||||
}
|
||||
55
src/components/ui/scroll-area.tsx
Normal file
55
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
25
src/components/ui/separator.tsx
Normal file
25
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
66
src/components/ui/tooltip.tsx
Normal file
66
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
366
src/components/workbench/Scratchpad.tsx
Normal file
366
src/components/workbench/Scratchpad.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from 'react';
|
||||
import { Pencil, Type, Eraser, Trash2, Minus, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Tool = 'pen' | 'text' | 'eraser';
|
||||
|
||||
interface TextBox {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const COLORS = ['#ffffff', '#ef4444', '#22c55e', '#3b82f6', '#eab308', '#a855f7'];
|
||||
const SIZES = [2, 4, 6];
|
||||
|
||||
export function Scratchpad() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [tool, setTool] = useState<Tool>('pen');
|
||||
const [color, setColor] = useState('#ffffff');
|
||||
const [strokeSize, setStrokeSize] = useState(2);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [textBoxes, setTextBoxes] = useState<TextBox[]>([]);
|
||||
const [dragging, setDragging] = useState<{ id: string; offsetX: number; offsetY: number } | null>(null);
|
||||
const [resizing, setResizing] = useState<{ id: string; startW: number; startH: number; startX: number; startY: number } | null>(null);
|
||||
const lastPoint = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Setup canvas resolution
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const resize = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
// Save current drawing
|
||||
const imageData = canvas.getContext('2d')?.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.scale(dpr, dpr);
|
||||
// Restore drawing if dimensions haven't changed too much
|
||||
if (imageData) {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = imageData.width;
|
||||
tempCanvas.height = imageData.height;
|
||||
tempCanvas.getContext('2d')?.putImageData(imageData, 0, 0);
|
||||
ctx.drawImage(tempCanvas, 0, 0, imageData.width / dpr, imageData.height / dpr);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const getPos = useCallback((e: ReactPointerEvent | globalThis.PointerEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const drawLine = useCallback(
|
||||
(from: { x: number; y: number }, to: { x: number; y: number }) => {
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.strokeStyle = tool === 'eraser' ? '#0a0a0a' : color;
|
||||
ctx.lineWidth = tool === 'eraser' ? strokeSize * 6 : strokeSize;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
},
|
||||
[color, strokeSize, tool]
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
if (tool === 'text') {
|
||||
// Place a new text box at click position
|
||||
const pos = getPos(e);
|
||||
const newBox: TextBox = {
|
||||
id: crypto.randomUUID(),
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: 160,
|
||||
height: 60,
|
||||
text: '',
|
||||
};
|
||||
setTextBoxes((prev) => [...prev, newBox]);
|
||||
setTool('pen'); // Switch back to pen after placing
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDrawing(true);
|
||||
lastPoint.current = getPos(e);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[tool, getPos]
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing || !lastPoint.current) return;
|
||||
const pos = getPos(e);
|
||||
drawLine(lastPoint.current, pos);
|
||||
lastPoint.current = pos;
|
||||
},
|
||||
[isDrawing, getPos, drawLine]
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
setIsDrawing(false);
|
||||
lastPoint.current = null;
|
||||
}, []);
|
||||
|
||||
const clearCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
|
||||
setTextBoxes([]);
|
||||
}, []);
|
||||
|
||||
const deleteTextBox = useCallback((id: string) => {
|
||||
setTextBoxes((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
// Global pointer move/up for dragging text boxes
|
||||
useEffect(() => {
|
||||
if (!dragging && !resizing) return;
|
||||
|
||||
// Prevent text selection while dragging/resizing
|
||||
const preventSelect = (e: Event) => e.preventDefault();
|
||||
document.addEventListener('selectstart', preventSelect);
|
||||
|
||||
const handleMove = (e: globalThis.PointerEvent) => {
|
||||
if (dragging) {
|
||||
setTextBoxes((prev) =>
|
||||
prev.map((b) =>
|
||||
b.id === dragging.id
|
||||
? { ...b, x: e.clientX - (containerRef.current?.getBoundingClientRect().left ?? 0) - dragging.offsetX, y: e.clientY - (containerRef.current?.getBoundingClientRect().top ?? 0) - dragging.offsetY }
|
||||
: b
|
||||
)
|
||||
);
|
||||
}
|
||||
if (resizing) {
|
||||
const dx = e.clientX - resizing.startX;
|
||||
const dy = e.clientY - resizing.startY;
|
||||
setTextBoxes((prev) =>
|
||||
prev.map((b) =>
|
||||
b.id === resizing.id
|
||||
? { ...b, width: Math.max(80, resizing.startW + dx), height: Math.max(30, resizing.startH + dy) }
|
||||
: b
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
setDragging(null);
|
||||
setResizing(null);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
return () => {
|
||||
document.removeEventListener('selectstart', preventSelect);
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
};
|
||||
}, [dragging, resizing]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[#0a0a0a] border-l border-border">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border bg-card/50 flex-wrap">
|
||||
<Button
|
||||
variant={tool === 'pen' ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
onClick={() => setTool('pen')}
|
||||
title="Lápiz"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool === 'text' ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
onClick={() => setTool('text')}
|
||||
title="Texto"
|
||||
>
|
||||
<Type className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool === 'eraser' ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
onClick={() => setTool('eraser')}
|
||||
title="Borrador"
|
||||
>
|
||||
<Eraser className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
{/* Colors */}
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => { setColor(c); if (tool === 'eraser') setTool('pen'); }}
|
||||
className={`w-5 h-5 rounded-full border-2 transition-transform ${
|
||||
color === c && tool !== 'eraser' ? 'border-primary scale-125' : 'border-transparent hover:scale-110'
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
{/* Stroke size */}
|
||||
{SIZES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStrokeSize(s)}
|
||||
className={`w-6 h-6 flex items-center justify-center rounded transition-colors ${
|
||||
strokeSize === s ? 'bg-primary/20' : 'hover:bg-muted'
|
||||
}`}
|
||||
title={`Grosor ${s}`}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-foreground"
|
||||
style={{ width: s + 2, height: s + 2 }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-7 h-7 text-destructive hover:text-destructive"
|
||||
onClick={clearCanvas}
|
||||
title="Limpiar todo"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Canvas area */}
|
||||
<div ref={containerRef} className="flex-1 relative overflow-hidden cursor-crosshair">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
style={{ touchAction: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Text boxes */}
|
||||
{textBoxes.map((box) => (
|
||||
<div
|
||||
key={box.id}
|
||||
className="absolute group"
|
||||
style={{
|
||||
left: box.x,
|
||||
top: box.y,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="absolute -top-5 left-0 right-0 h-5 flex items-center justify-between px-1 opacity-0 group-hover:opacity-100 transition-opacity cursor-move"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
setDragging({ id: box.id, offsetX: e.nativeEvent.offsetX, offsetY: e.nativeEvent.offsetY + 20 });
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="w-1 h-1 rounded-full bg-muted-foreground" />
|
||||
<div className="w-1 h-1 rounded-full bg-muted-foreground" />
|
||||
<div className="w-1 h-1 rounded-full bg-muted-foreground" />
|
||||
</div>
|
||||
<button
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
onClick={() => deleteTextBox(box.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={box.text}
|
||||
onChange={(e) =>
|
||||
setTextBoxes((prev) =>
|
||||
prev.map((b) => (b.id === box.id ? { ...b, text: e.target.value } : b))
|
||||
)
|
||||
}
|
||||
placeholder="Escribe aquí..."
|
||||
className="w-full h-full bg-white/5 border border-white/20 rounded text-sm text-foreground p-2 resize-none focus:outline-none focus:border-primary/50 placeholder:text-muted-foreground/40"
|
||||
style={{ fontSize: 13 }}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="absolute -bottom-1 -right-1 w-3 h-3 cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
setResizing({
|
||||
id: box.id,
|
||||
startW: box.width,
|
||||
startH: box.height,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 10 10" className="w-full h-full text-muted-foreground">
|
||||
<path d="M9 1L1 9M9 5L5 9" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Tool indicator */}
|
||||
{tool === 'text' && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 px-3 py-1 bg-card/80 border border-border rounded-full text-xs text-muted-foreground pointer-events-none">
|
||||
Haz clic para colocar un cuadro de texto
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
433
src/components/workbench/WorkbenchShell.tsx
Normal file
433
src/components/workbench/WorkbenchShell.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Lightbulb, Clock, CheckCircle2, XCircle, ArrowRight, Eye, PenTool, BookOpen, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Challenge } from '@/types/challenge';
|
||||
import { verifyAnswer } from '@/lib/challenge-engine/verifier';
|
||||
import { useProgressStore } from '@/stores/useProgressStore';
|
||||
import { getChallengesForNode } from '@/data/challenges/math';
|
||||
import { MathInput } from './modules/MathInput';
|
||||
import { MultipleChoice } from './modules/MultipleChoice';
|
||||
import { Scratchpad } from './Scratchpad';
|
||||
|
||||
const MAX_ATTEMPTS_BEFORE_REVEAL = 3;
|
||||
|
||||
interface WorkbenchShellProps {
|
||||
challenge: Challenge;
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd className="inline-flex items-center justify-center px-1.5 py-0.5 text-[10px] font-mono font-medium bg-muted border border-border rounded text-muted-foreground">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
type Phase = 'answering' | 'wrong-shake' | 'correct' | 'revealed';
|
||||
|
||||
export function WorkbenchShell({ challenge }: WorkbenchShellProps) {
|
||||
const router = useRouter();
|
||||
const completeChallenge = useProgressStore((s) => s.completeChallenge);
|
||||
const completedChallenges = useProgressStore((s) => s.completedChallenges);
|
||||
|
||||
const [answer, setAnswer] = useState<string | number>('');
|
||||
const [phase, setPhase] = useState<Phase>('answering');
|
||||
const [attempts, setAttempts] = useState(0);
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const [hintIndex, setHintIndex] = useState(0);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [canProceed, setCanProceed] = useState(false);
|
||||
const shakeTimeout = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const [showScratchpad, setShowScratchpad] = useState(true);
|
||||
const [showExplanation, setShowExplanation] = useState(!!challenge.explanation);
|
||||
|
||||
const isAlreadyCompleted = !!completedChallenges[challenge.id];
|
||||
const solved = phase === 'correct';
|
||||
const revealed = phase === 'revealed';
|
||||
const done = solved || revealed;
|
||||
|
||||
const getNextChallenge = useCallback((): Challenge | null => {
|
||||
const nodeChallenges = getChallengesForNode(challenge.nodeId);
|
||||
const currentIndex = nodeChallenges.findIndex((c) => c.id === challenge.id);
|
||||
for (let i = currentIndex + 1; i < nodeChallenges.length; i++) {
|
||||
if (!completedChallenges[nodeChallenges[i].id]) return nodeChallenges[i];
|
||||
}
|
||||
for (let i = 0; i < currentIndex; i++) {
|
||||
if (!completedChallenges[nodeChallenges[i].id]) return nodeChallenges[i];
|
||||
}
|
||||
return null;
|
||||
}, [challenge.id, challenge.nodeId, completedChallenges]);
|
||||
|
||||
// Timer
|
||||
useEffect(() => {
|
||||
if (done) return;
|
||||
const interval = setInterval(() => setElapsedTime((t) => t + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [done]);
|
||||
|
||||
// Cleanup shake timeout
|
||||
useEffect(() => {
|
||||
return () => { if (shakeTimeout.current) clearTimeout(shakeTimeout.current); };
|
||||
}, []);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (answer === '' || answer === undefined || done) return;
|
||||
const verification = verifyAnswer(challenge, answer);
|
||||
|
||||
if (verification.correct) {
|
||||
setPhase('correct');
|
||||
setCanProceed(false);
|
||||
setTimeout(() => setCanProceed(true), 800);
|
||||
if (!isAlreadyCompleted) {
|
||||
completeChallenge(challenge.id, elapsedTime);
|
||||
}
|
||||
} else {
|
||||
const newAttempts = attempts + 1;
|
||||
setAttempts(newAttempts);
|
||||
// Brief shake feedback, then clear input to try again
|
||||
setPhase('wrong-shake');
|
||||
shakeTimeout.current = setTimeout(() => {
|
||||
setPhase('answering');
|
||||
setAnswer('');
|
||||
}, 600);
|
||||
}
|
||||
}, [answer, challenge, completeChallenge, elapsedTime, isAlreadyCompleted, attempts, done]);
|
||||
|
||||
const handleRevealSolution = useCallback(() => {
|
||||
setPhase('revealed');
|
||||
setCanProceed(false);
|
||||
setTimeout(() => setCanProceed(true), 800);
|
||||
}, []);
|
||||
|
||||
const handleNextChallenge = useCallback(() => {
|
||||
const next = getNextChallenge();
|
||||
if (next) {
|
||||
router.push(`/workbench/${encodeURIComponent(next.id)}`);
|
||||
} else {
|
||||
router.push('/skill-tree');
|
||||
}
|
||||
}, [getNextChallenge, router]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push('/skill-tree');
|
||||
};
|
||||
|
||||
const handleNextHint = () => {
|
||||
if (hintIndex < challenge.hints.length - 1) {
|
||||
setHintIndex((i) => i + 1);
|
||||
}
|
||||
setShowHint(true);
|
||||
};
|
||||
|
||||
const getSolutionText = (): string => {
|
||||
if (challenge.content.type === 'math-input') {
|
||||
return String(challenge.content.answer.value);
|
||||
}
|
||||
if (challenge.content.type === 'multiple-choice') {
|
||||
return challenge.content.options[challenge.content.correctIndex];
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Global keyboard handler
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
const isInput = tag === 'INPUT' || tag === 'TEXTAREA';
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (!done && phase !== 'wrong-shake') {
|
||||
handleSubmit();
|
||||
} else if (done && canProceed) {
|
||||
handleNextChallenge();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// H for hint
|
||||
if (e.key === 'h' && !isInput && !done && phase !== 'wrong-shake') {
|
||||
e.preventDefault();
|
||||
handleNextHint();
|
||||
return;
|
||||
}
|
||||
|
||||
// S to reveal solution (only when enough attempts)
|
||||
if (e.key === 's' && !isInput && !done && attempts >= MAX_ATTEMPTS_BEFORE_REVEAL) {
|
||||
e.preventDefault();
|
||||
handleRevealSolution();
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple choice: number keys 1-9
|
||||
if (challenge.content.type === 'multiple-choice' && !isInput && !done && phase !== 'wrong-shake') {
|
||||
const num = parseInt(e.key);
|
||||
if (num >= 1 && num <= challenge.content.options.length) {
|
||||
e.preventDefault();
|
||||
setAnswer(num - 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [phase, done, canProceed, attempts, handleSubmit, handleNextChallenge, handleRevealSolution, challenge.content]);
|
||||
|
||||
const nextChallenge = getNextChallenge();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Top bar */}
|
||||
<header className="px-4 py-3 border-b border-border bg-card/50 backdrop-blur-sm flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={handleBack} title="Volver (Esc)">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-base font-bold">{challenge.title}</h1>
|
||||
<p className="text-xs text-muted-foreground">+{challenge.xpReward} XP</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatTime(elapsedTime)}
|
||||
</div>
|
||||
{attempts > 0 && !done && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Intento {attempts}
|
||||
</div>
|
||||
)}
|
||||
{isAlreadyCompleted && (
|
||||
<div className="flex items-center gap-1 text-green-500 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Completado
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant={showScratchpad ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
className="w-8 h-8"
|
||||
onClick={() => setShowScratchpad((v) => !v)}
|
||||
title="Libreta"
|
||||
>
|
||||
<PenTool className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Split layout: challenge + scratchpad */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Challenge panel */}
|
||||
<div className={`overflow-auto p-4 md:p-8 ${showScratchpad ? 'w-1/2' : 'w-full'} transition-all`}>
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
{/* Explanation / Theory */}
|
||||
{challenge.explanation && (
|
||||
<Card className="border-blue-500/20 bg-blue-500/5 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowExplanation((v) => !v)}
|
||||
className="w-full px-6 py-4 flex items-center gap-2 text-left hover:bg-blue-500/5 transition-colors"
|
||||
>
|
||||
<BookOpen className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
<h2 className="text-sm font-semibold text-blue-400 flex-1">Aprende primero</h2>
|
||||
{showExplanation ? (
|
||||
<ChevronUp className="w-4 h-4 text-blue-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-blue-400" />
|
||||
)}
|
||||
</button>
|
||||
{showExplanation && (
|
||||
<div className="px-6 pb-5 pt-0">
|
||||
<div className="text-sm leading-relaxed whitespace-pre-line text-foreground/85">
|
||||
{challenge.explanation}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Problem statement */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-2">Problema</h2>
|
||||
<p className="text-base leading-relaxed">{challenge.description}</p>
|
||||
</Card>
|
||||
|
||||
{/* Workbench module */}
|
||||
<Card className={`p-6 transition-all ${phase === 'wrong-shake' ? 'animate-shake border-red-500/50' : ''}`}>
|
||||
<h2 className="text-lg font-semibold mb-4">Tu respuesta</h2>
|
||||
{challenge.content.type === 'math-input' && (
|
||||
<MathInput
|
||||
value={answer as string}
|
||||
onChange={setAnswer}
|
||||
onSubmit={handleSubmit}
|
||||
disabled={done || phase === 'wrong-shake'}
|
||||
/>
|
||||
)}
|
||||
{challenge.content.type === 'multiple-choice' && (
|
||||
<MultipleChoice
|
||||
options={challenge.content.options}
|
||||
selected={answer as number}
|
||||
onChange={setAnswer}
|
||||
disabled={done || phase === 'wrong-shake'}
|
||||
/>
|
||||
)}
|
||||
{/* Wrong attempt feedback inline */}
|
||||
{phase === 'wrong-shake' && (
|
||||
<p className="text-sm text-red-400 mt-3 text-center">
|
||||
Incorrecto. Inténtalo de nuevo.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Hint section */}
|
||||
{challenge.hints.length > 0 && !done && phase !== 'wrong-shake' && (
|
||||
<div>
|
||||
{showHint ? (
|
||||
<Card className="p-4 bg-amber-500/5 border-amber-500/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-500 mb-1">
|
||||
Pista {hintIndex + 1}/{challenge.hints.length}
|
||||
</p>
|
||||
<p className="text-sm">{challenge.hints[hintIndex]}</p>
|
||||
{hintIndex < challenge.hints.length - 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 text-amber-500 hover:text-amber-400"
|
||||
onClick={handleNextHint}
|
||||
>
|
||||
Siguiente pista <Kbd>H</Kbd>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-amber-500 border-amber-500/30 hover:bg-amber-500/10"
|
||||
onClick={handleNextHint}
|
||||
>
|
||||
<Lightbulb className="w-4 h-4 mr-1" />
|
||||
Mostrar pista <Kbd>H</Kbd>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reveal solution button — only after enough failed attempts */}
|
||||
{!done && phase !== 'wrong-shake' && attempts >= MAX_ATTEMPTS_BEFORE_REVEAL && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-muted-foreground border-border hover:bg-muted"
|
||||
onClick={handleRevealSolution}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
Ver solución <Kbd>S</Kbd>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Result feedback — correct */}
|
||||
{solved && (
|
||||
<Card className="p-4 bg-green-500/10 border-green-500/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<p className="font-medium">¡Correcto! 🎉</p>
|
||||
</div>
|
||||
{!isAlreadyCompleted && (
|
||||
<p className="text-sm text-green-500 mt-1 ml-7">
|
||||
+{challenge.xpReward} XP ganados
|
||||
{attempts === 0 && ' — ¡a la primera!'}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Result feedback — revealed */}
|
||||
{revealed && (
|
||||
<Card className="p-4 bg-muted/50 border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Eye className="w-5 h-5 text-muted-foreground" />
|
||||
<p className="font-medium text-muted-foreground">Solución</p>
|
||||
</div>
|
||||
<p className="text-lg font-mono font-bold ml-7">{getSolutionText()}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2 ml-7">
|
||||
No se otorga XP al revelar la solución. Intenta recordarla para la próxima.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Submit / Continue */}
|
||||
<div className="flex gap-3">
|
||||
{!done && phase !== 'wrong-shake' ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={answer === '' || answer === undefined}
|
||||
className="flex-1"
|
||||
size="lg"
|
||||
>
|
||||
Verificar respuesta <Kbd>Enter</Kbd>
|
||||
</Button>
|
||||
) : done ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleNextChallenge}
|
||||
className="flex-1"
|
||||
size="lg"
|
||||
disabled={!canProceed}
|
||||
autoFocus
|
||||
>
|
||||
{nextChallenge ? 'Siguiente reto' : 'Volver al árbol'}
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
<Kbd>Enter</Kbd>
|
||||
</Button>
|
||||
{nextChallenge && (
|
||||
<Button onClick={handleBack} variant="outline" size="lg">
|
||||
Árbol <Kbd>Esc</Kbd>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Keyboard shortcuts legend */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground justify-center pt-2">
|
||||
<span><Kbd>Enter</Kbd> Verificar / Siguiente</span>
|
||||
<span><Kbd>Esc</Kbd> Volver al árbol</span>
|
||||
{challenge.hints.length > 0 && !done && <span><Kbd>H</Kbd> Pista</span>}
|
||||
{!done && attempts >= MAX_ATTEMPTS_BEFORE_REVEAL && <span><Kbd>S</Kbd> Ver solución</span>}
|
||||
{challenge.content.type === 'multiple-choice' && !done && <span><Kbd>1</Kbd>-<Kbd>{challenge.content.options.length}</Kbd> Seleccionar</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scratchpad panel */}
|
||||
{showScratchpad && (
|
||||
<div className="w-1/2 shrink-0">
|
||||
<Scratchpad />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/workbench/modules/MathInput.tsx
Normal file
102
src/components/workbench/modules/MathInput.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
|
||||
interface MathInputProps {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
onSubmit: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function MathInput({ value, onChange, onSubmit, disabled }: MathInputProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [rtl, setRtl] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) inputRef.current?.focus();
|
||||
}, [disabled]);
|
||||
|
||||
// In RTL mode we intercept all key input manually.
|
||||
// Each new digit is prepended (inserted at the left).
|
||||
// Backspace removes the leftmost digit.
|
||||
// The stored `value` is always the final number in normal reading order.
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (e.key === 'Enter' && value !== '') {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rtl) return; // In LTR mode, let the browser handle it normally
|
||||
|
||||
// Prevent default so we control all input
|
||||
const isDigit = /^[0-9]$/.test(e.key);
|
||||
const isDot = e.key === '.';
|
||||
const isMinus = e.key === '-';
|
||||
const isBackspace = e.key === 'Backspace';
|
||||
|
||||
if (isDigit || isDot || isMinus) {
|
||||
e.preventDefault();
|
||||
// Prepend the character
|
||||
onChange(e.key + value);
|
||||
} else if (isBackspace) {
|
||||
e.preventDefault();
|
||||
// Remove the first character (the last one typed)
|
||||
onChange(value.slice(1));
|
||||
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
||||
// Block any other printable character
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[disabled, rtl, value, onChange, onSubmit]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (disabled || rtl) return; // RTL is handled via keydown
|
||||
onChange(e.target.value);
|
||||
},
|
||||
[disabled, rtl, onChange]
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setRtl((prev) => !prev);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder={rtl ? '...escribe desde las unidades' : 'Escribe tu respuesta...'}
|
||||
className={`w-full px-4 py-3 text-2xl font-mono rounded-lg bg-muted border-2 border-border focus:border-primary focus:outline-none transition-colors disabled:opacity-60 ${
|
||||
rtl ? 'text-right' : 'text-center'
|
||||
}`}
|
||||
/>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-xs transition-colors ${
|
||||
rtl
|
||||
? 'bg-primary/15 text-primary border border-primary/30'
|
||||
: 'bg-muted text-muted-foreground border border-border hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono font-bold text-sm">{rtl ? '←' : '→'}</span>
|
||||
{rtl ? 'Derecha a izquierda' : 'Izquierda a derecha'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/workbench/modules/MultipleChoice.tsx
Normal file
45
src/components/workbench/modules/MultipleChoice.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
interface MultipleChoiceProps {
|
||||
options: string[];
|
||||
selected: number | string;
|
||||
onChange: (val: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function MultipleChoice({ options, selected, onChange, disabled }: MultipleChoiceProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{options.map((option, index) => {
|
||||
const isSelected = selected === index;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => !disabled && onChange(index)}
|
||||
disabled={disabled}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-muted/30 text-foreground hover:border-primary/30 hover:bg-muted/50'
|
||||
} disabled:opacity-60 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0 text-[10px] font-mono font-bold ${
|
||||
isSelected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/30 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? (
|
||||
<div className="w-2 h-2 rounded-full bg-primary-foreground" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{option}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
src/data/challenges/math.ts
Normal file
204
src/data/challenges/math.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Challenge } from '@/types/challenge';
|
||||
|
||||
function mathChallenge(
|
||||
id: string,
|
||||
nodeId: string,
|
||||
title: string,
|
||||
problem: string,
|
||||
answer: number,
|
||||
difficulty: 1 | 2 | 3 | 4 | 5 = 1,
|
||||
hints: string[] = [],
|
||||
tolerance = 0,
|
||||
explanation?: string
|
||||
): Challenge {
|
||||
return {
|
||||
id: `${nodeId}/${id}`,
|
||||
nodeId,
|
||||
title,
|
||||
description: problem,
|
||||
difficulty,
|
||||
type: 'math-input',
|
||||
hints,
|
||||
xpReward: difficulty * 20,
|
||||
explanation,
|
||||
content: {
|
||||
type: 'math-input',
|
||||
problem,
|
||||
answer: { type: 'numeric', value: answer, tolerance },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const arithmeticChallenges: Challenge[] = [
|
||||
// Addition
|
||||
mathChallenge('add-01', 'arithmetic.addition', 'Suma simple', '¿Cuánto es 7 + 5?', 12, 1, ['Cuenta desde 7 hacia arriba'], 0,
|
||||
'La suma es la operación más básica. Combina dos cantidades en una sola.\n\nPor ejemplo: si tienes 3 manzanas y te dan 2 más, ahora tienes 3 + 2 = 5 manzanas.\n\nTruco: para sumar mentalmente, empieza por el número más grande y cuenta hacia arriba. Por ejemplo, para 7 + 5: empieza en 7 y cuenta 5 más → 8, 9, 10, 11, 12.'),
|
||||
mathChallenge('add-02', 'arithmetic.addition', 'Suma de dos cifras', '¿Cuánto es 34 + 27?', 61, 1, ['Suma las unidades primero: 4+7=11']),
|
||||
mathChallenge('add-03', 'arithmetic.addition', 'Suma de tres números', '¿Cuánto es 15 + 23 + 42?', 80, 1, ['Suma de dos en dos']),
|
||||
mathChallenge('add-04', 'arithmetic.addition', 'Suma con centenas', '¿Cuánto es 256 + 189?', 445, 1, ['Empieza por las unidades: 6+9=15']),
|
||||
|
||||
// Subtraction
|
||||
mathChallenge('sub-01', 'arithmetic.subtraction', 'Resta simple', '¿Cuánto es 15 - 8?', 7, 1, ['Cuenta hacia atrás desde 15'], 0,
|
||||
'La resta es la operación inversa de la suma. Quita una cantidad de otra.\n\nPor ejemplo: si tienes 10 galletas y comes 3, te quedan 10 - 3 = 7.\n\nTruco: puedes pensar "¿qué le sumo a 8 para llegar a 15?". Si 8 + 7 = 15, entonces 15 - 8 = 7.'),
|
||||
mathChallenge('sub-02', 'arithmetic.subtraction', 'Resta de dos cifras', '¿Cuánto es 82 - 47?', 35, 1, ['Necesitas "pedir prestado" en las unidades']),
|
||||
mathChallenge('sub-03', 'arithmetic.subtraction', 'Resta con centenas', '¿Cuánto es 500 - 237?', 263, 1),
|
||||
|
||||
// Multiplication
|
||||
mathChallenge('mul-01', 'arithmetic.multiplication', 'Multiplicación básica', '¿Cuánto es 6 × 7?', 42, 1, ['Piensa en 6 grupos de 7'], 0,
|
||||
'La multiplicación es una suma repetida. 6 × 7 significa "sumar 7 seis veces" (o "sumar 6 siete veces").\n\n6 × 7 = 7 + 7 + 7 + 7 + 7 + 7 = 42\n\nAprender las tablas de multiplicar de memoria es muy útil. Truco: si no recuerdas 6×7, piensa en 6×5=30 y luego suma 6×2=12 → 30+12=42.'),
|
||||
mathChallenge('mul-02', 'arithmetic.multiplication', 'Multiplicación de dos cifras', '¿Cuánto es 12 × 15?', 180, 1, ['12 × 15 = 12 × 10 + 12 × 5']),
|
||||
mathChallenge('mul-03', 'arithmetic.multiplication', 'Multiplicación avanzada', '¿Cuánto es 25 × 32?', 800, 2, ['25 × 32 = 25 × 4 × 8']),
|
||||
|
||||
// Division
|
||||
mathChallenge('div-01', 'arithmetic.division', 'División exacta', '¿Cuánto es 56 ÷ 8?', 7, 1, ['¿Qué número × 8 = 56?'], 0,
|
||||
'La división es la operación inversa de la multiplicación. Reparte una cantidad en partes iguales.\n\n56 ÷ 8 significa: "¿en cuántos grupos de 8 cabe 56?" o "si reparto 56 entre 8, ¿cuánto toca a cada uno?"\n\nTruco: piensa "¿qué número multiplicado por 8 da 56?". Como 7 × 8 = 56, entonces 56 ÷ 8 = 7.'),
|
||||
mathChallenge('div-02', 'arithmetic.division', 'División de dos cifras', '¿Cuánto es 144 ÷ 12?', 12, 1),
|
||||
mathChallenge('div-03', 'arithmetic.division', 'División con decimales', '¿Cuánto es 7 ÷ 4?', 1.75, 2, ['Divide y continúa con decimales'], 0.01),
|
||||
];
|
||||
|
||||
export const fractionChallenges: Challenge[] = [
|
||||
mathChallenge('frac-01', 'arithmetic.fractions', 'Suma de fracciones', '¿Cuánto es 1/3 + 1/6?', 0.5, 2, ['Encuentra un denominador común: 6'], 0.01,
|
||||
'Las fracciones representan partes de un todo. 1/3 = "una de tres partes", 1/6 = "una de seis partes".\n\nPara sumar fracciones necesitas el mismo denominador (la parte de abajo):\n• 1/3 = 2/6 (multiplica arriba y abajo por 2)\n• 2/6 + 1/6 = 3/6 = 1/2 = 0.5\n\nRegla: busca el mínimo común denominador, convierte ambas fracciones, y luego suma los numeradores.'),
|
||||
mathChallenge('frac-02', 'arithmetic.fractions', 'Multiplicación de fracciones', '¿Cuánto es 2/3 × 3/4?', 0.5, 2, ['Multiplica numerador con numerador y denominador con denominador'], 0.01),
|
||||
mathChallenge('frac-03', 'arithmetic.fractions', 'Fracción a decimal', '¿Cuánto es 5/8 en decimal?', 0.625, 2, ['Divide 5 entre 8'], 0.001),
|
||||
];
|
||||
|
||||
export const decimalChallenges: Challenge[] = [
|
||||
mathChallenge('dec-01', 'arithmetic.decimals', 'Suma de decimales', '¿Cuánto es 3.7 + 2.85?', 6.55, 2, ['Alinea los puntos decimales'], 0.01,
|
||||
'Los decimales son otra forma de escribir fracciones. 3.7 = 3 + 7/10, y 2.85 = 2 + 85/100.\n\nPara sumar decimales, alinea los puntos decimales y suma columna por columna:\n 3.70\n+ 2.85\n------\n 6.55\n\nTruco: si un número tiene menos decimales, añade ceros al final (3.7 → 3.70) para que sea más fácil alinearlos.'),
|
||||
mathChallenge('dec-02', 'arithmetic.decimals', 'Redondeo', 'Redondea 3.746 a dos decimales', 3.75, 2, ['Mira el tercer decimal: 6 ≥ 5, sube'], 0.001),
|
||||
mathChallenge('dec-03', 'arithmetic.decimals', 'Multiplicación decimal', '¿Cuánto es 2.5 × 0.4?', 1, 2, ['2.5 × 0.4 = 25 × 4 ÷ 100']),
|
||||
];
|
||||
|
||||
export const percentageChallenges: Challenge[] = [
|
||||
mathChallenge('pct-01', 'arithmetic.percentages', 'Porcentaje básico', '¿Cuánto es el 25% de 200?', 50, 2, ['25% = 1/4'], 0,
|
||||
'Porcentaje significa "por cada cien". 25% = 25/100 = 0.25\n\nPara calcular un porcentaje de un número, multiplica el número por el porcentaje en decimal:\n• 25% de 200 = 0.25 × 200 = 50\n\nAtajos útiles:\n• 50% = la mitad\n• 25% = un cuarto\n• 10% = mover el punto decimal una posición a la izquierda\n• 1% = mover el punto decimal dos posiciones'),
|
||||
mathChallenge('pct-02', 'arithmetic.percentages', 'Descuento', 'Un artículo cuesta 80€. Con 15% de descuento, ¿cuánto pagas?', 68, 2, ['Calcula el 15% de 80 y réstalo']),
|
||||
mathChallenge('pct-03', 'arithmetic.percentages', 'Porcentaje inverso', 'Si 30 es el 60% de un número, ¿cuál es ese número?', 50, 2, ['30 = 0.6 × x, entonces x = 30/0.6']),
|
||||
];
|
||||
|
||||
export const primeChallenges: Challenge[] = [
|
||||
mathChallenge('prime-01', 'number-theory.primes', '¿Es primo?', '¿Cuántos números primos hay entre 1 y 20?', 8, 2, ['Los primos son: 2, 3, 5, 7, 11, 13, 17, 19'], 0,
|
||||
'Un número primo es un número mayor que 1 que solo es divisible por 1 y por sí mismo.\n\nEjemplos:\n• 2 es primo (solo divisible por 1 y 2)\n• 4 NO es primo (divisible por 1, 2 y 4)\n• 7 es primo\n• 9 NO es primo (3 × 3 = 9)\n\nEl 1 no se considera primo. El 2 es el único primo par.\n\nPara verificar si un número es primo, comprueba si es divisible por algún número hasta su raíz cuadrada.'),
|
||||
mathChallenge('prime-02', 'number-theory.primes', 'Factorización', '¿Cuál es el factor primo más grande de 84?', 7, 2, ['84 = 2 × 42 = 2 × 2 × 21 = 2 × 2 × 3 × 7']),
|
||||
mathChallenge('prime-03', 'number-theory.primes', 'Primo siguiente', '¿Cuál es el siguiente número primo después de 23?', 29, 2, ['Comprueba 24, 25, 26, 27, 28, 29...']),
|
||||
];
|
||||
|
||||
export const gcdLcmChallenges: Challenge[] = [
|
||||
mathChallenge('gcd-01', 'number-theory.gcd-lcm', 'MCD básico', '¿Cuál es el MCD de 24 y 36?', 12, 2, ['Factoriza: 24=2³×3, 36=2²×3²'], 0,
|
||||
'El MCD (Máximo Común Divisor) es el número más grande que divide a dos números exactamente.\n\nMétodo de factorización:\n1. Descompón cada número en factores primos:\n • 24 = 2 × 2 × 2 × 3 = 2³ × 3\n • 36 = 2 × 2 × 3 × 3 = 2² × 3²\n2. Toma los factores comunes con el menor exponente:\n • Factor 2: mín(3,2) = 2² = 4\n • Factor 3: mín(1,2) = 3¹ = 3\n3. Multiplica: 4 × 3 = 12\n\nEl MCD de 24 y 36 es 12.\n\nMétodo alternativo (Euclides): divide el mayor entre el menor, luego el menor entre el resto, repite hasta que el resto sea 0. El último divisor es el MCD.\n• 36 ÷ 24 = 1 resto 12\n• 24 ÷ 12 = 2 resto 0 → MCD = 12'),
|
||||
mathChallenge('gcd-02', 'number-theory.gcd-lcm', 'MCM básico', '¿Cuál es el MCM de 6 y 8?', 24, 2, ['MCM = (6×8)/MCD(6,8)'], 0,
|
||||
'El MCM (Mínimo Común Múltiplo) es el número más pequeño que es múltiplo de ambos números.\n\n¡No confundir con el MCD!\n• MCD = el mayor número que DIVIDE a ambos (busca divisores)\n• MCM = el menor número que es MÚLTIPLO de ambos (busca múltiplos)\n\nMétodo 1 — listar múltiplos:\n• Múltiplos de 6: 6, 12, 18, 24, 30...\n• Múltiplos de 8: 8, 16, 24, 32...\n• El primero en común es 24\n\nMétodo 2 — fórmula rápida:\nMCM(a,b) = (a × b) / MCD(a,b)\nMCM(6,8) = (6 × 8) / MCD(6,8) = 48 / 2 = 24\n\nEl MCM se usa mucho para sumar fracciones con distinto denominador.'),
|
||||
mathChallenge('gcd-03', 'number-theory.gcd-lcm', 'MCD de tres números', '¿Cuál es el MCD de 12, 18 y 30?', 6, 2),
|
||||
];
|
||||
|
||||
export const variableChallenges: Challenge[] = [
|
||||
mathChallenge('var-01', 'algebra.variables', 'Evaluar expresión', 'Si x = 3, ¿cuánto vale 2x + 5?', 11, 2, ['Sustituye x por 3: 2(3) + 5'], 0,
|
||||
'En álgebra usamos letras (variables) para representar números desconocidos.\n\n"2x" significa "2 multiplicado por x". Si x = 3:\n• 2x = 2 × 3 = 6\n• 2x + 5 = 6 + 5 = 11\n\nEvaluar una expresión es sustituir la variable por su valor y calcular el resultado. Siempre resuelve multiplicaciones antes que sumas (orden de operaciones).'),
|
||||
mathChallenge('var-02', 'algebra.variables', 'Expresión con dos variables', 'Si a = 4 y b = 7, ¿cuánto vale 3a - b + 2?', 7, 2, ['3(4) - 7 + 2 = 12 - 7 + 2']),
|
||||
mathChallenge('var-03', 'algebra.variables', 'Simplificar', 'Simplifica: 3x + 2x - x. ¿Cuántos "x" quedan?', 4, 2, ['Suma los coeficientes: 3+2-1']),
|
||||
];
|
||||
|
||||
export const equationChallenges: Challenge[] = [
|
||||
mathChallenge('eq-01', 'algebra.equations', 'Ecuación simple', 'Resuelve: x + 7 = 15', 8, 3, ['Resta 7 de ambos lados'], 0,
|
||||
'Una ecuación es una igualdad con una incógnita (x). Resolverla es encontrar el valor de x.\n\nRegla de oro: lo que hagas a un lado, hazlo al otro.\n\nEjemplo: x + 7 = 15\n• Queremos x sola → restamos 7 de ambos lados\n• x + 7 - 7 = 15 - 7\n• x = 8\n\nComprobación: 8 + 7 = 15 ✓\n\nOperaciones inversas: suma↔resta, multiplicación↔división.'),
|
||||
mathChallenge('eq-02', 'algebra.equations', 'Ecuación con multiplicación', 'Resuelve: 3x = 21', 7, 3, ['Divide ambos lados entre 3']),
|
||||
mathChallenge('eq-03', 'algebra.equations', 'Ecuación de dos pasos', 'Resuelve: 2x + 5 = 17', 6, 3, ['Primero resta 5, luego divide entre 2']),
|
||||
mathChallenge('eq-04', 'algebra.equations', 'Ecuación con paréntesis', 'Resuelve: 3(x - 2) = 15', 7, 3, ['Distribuye: 3x - 6 = 15']),
|
||||
];
|
||||
|
||||
export const linearSystemChallenges: Challenge[] = [
|
||||
mathChallenge('sys-01', 'algebra.linear-systems', 'Sistema simple', 'Resuelve: x + y = 10, x - y = 2. ¿Cuánto vale x?', 6, 3, ['Suma ambas ecuaciones: 2x = 12'], 0,
|
||||
'Un sistema de ecuaciones son dos (o más) ecuaciones que deben cumplirse a la vez.\n\nMétodo de eliminación:\n1. Suma o resta las ecuaciones para eliminar una variable\n2. Resuelve la variable que queda\n3. Sustituye para encontrar la otra\n\nEjemplo:\n x + y = 10\n x - y = 2\n\nSumando ambas: (x+y) + (x-y) = 10+2 → 2x = 12 → x = 6\nSustituyendo: 6 + y = 10 → y = 4'),
|
||||
mathChallenge('sys-02', 'algebra.linear-systems', 'Sistema por sustitución', 'Resuelve: y = 2x, x + y = 9. ¿Cuánto vale x?', 3, 3, ['Sustituye y: x + 2x = 9']),
|
||||
mathChallenge('sys-03', 'algebra.linear-systems', 'Sistema avanzado', 'Resuelve: 2x + 3y = 16, x - y = 3. ¿Cuánto vale y?', 2, 3, ['De la segunda: x = y + 3. Sustituye en la primera.']),
|
||||
];
|
||||
|
||||
export const quadraticChallenges: Challenge[] = [
|
||||
mathChallenge('quad-01', 'algebra.quadratics', 'Cuadrática simple', 'Resuelve: x² = 25. Da la solución positiva.', 5, 3, ['√25 = 5'], 0,
|
||||
'Una ecuación cuadrática contiene x² (x al cuadrado). La forma general es ax² + bx + c = 0.\n\nEl caso más simple: x² = número\n• Solución: x = ±√número\n• x² = 25 → x = +5 o x = -5 (porque tanto 5×5 como (-5)×(-5) dan 25)\n\nPara ecuaciones más complejas, se usa la fórmula general:\nx = (-b ± √(b²-4ac)) / 2a\n\nO se intenta factorizar: x²-5x+6 = (x-2)(x-3) = 0 → x=2 o x=3'),
|
||||
mathChallenge('quad-02', 'algebra.quadratics', 'Factorización', 'Resuelve: x² - 5x + 6 = 0. Da la solución mayor.', 3, 3, ['Factoriza: (x-2)(x-3) = 0']),
|
||||
mathChallenge('quad-03', 'algebra.quadratics', 'Fórmula general', 'Resuelve: x² + 2x - 8 = 0. Da la solución positiva.', 2, 3, ['Usa: x = (-b ± √(b²-4ac)) / 2a']),
|
||||
];
|
||||
|
||||
export const booleanChallenges: Challenge[] = [
|
||||
{
|
||||
id: 'logic.boolean/bool-01',
|
||||
nodeId: 'logic.boolean',
|
||||
title: 'AND básico',
|
||||
description: 'En lógica booleana, ¿cuál es el resultado de TRUE AND FALSE?',
|
||||
difficulty: 2,
|
||||
type: 'multiple-choice',
|
||||
hints: ['AND solo es TRUE cuando ambos operandos son TRUE'],
|
||||
xpReward: 40,
|
||||
explanation: 'La lógica booleana trabaja con dos valores: TRUE (verdadero) y FALSE (falso).\n\nOperadores básicos:\n\n• AND (Y): Solo es TRUE si AMBOS son TRUE\n TRUE AND TRUE = TRUE\n TRUE AND FALSE = FALSE\n FALSE AND FALSE = FALSE\n\n• OR (O): Es TRUE si AL MENOS UNO es TRUE\n TRUE OR FALSE = TRUE\n FALSE OR FALSE = FALSE\n\n• NOT (NO): Invierte el valor\n NOT TRUE = FALSE\n NOT FALSE = TRUE\n\nEstos operadores son la base de toda la computación y los circuitos digitales.',
|
||||
content: {
|
||||
type: 'multiple-choice',
|
||||
question: '¿Cuál es el resultado de TRUE AND FALSE?',
|
||||
options: ['TRUE', 'FALSE', 'NULL', 'ERROR'],
|
||||
correctIndex: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'logic.boolean/bool-02',
|
||||
nodeId: 'logic.boolean',
|
||||
title: 'OR básico',
|
||||
description: '¿Cuál es el resultado de FALSE OR TRUE?',
|
||||
difficulty: 2,
|
||||
type: 'multiple-choice',
|
||||
hints: ['OR es TRUE cuando al menos uno es TRUE'],
|
||||
xpReward: 40,
|
||||
content: {
|
||||
type: 'multiple-choice',
|
||||
question: '¿Cuál es el resultado de FALSE OR TRUE?',
|
||||
options: ['TRUE', 'FALSE'],
|
||||
correctIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'logic.boolean/bool-03',
|
||||
nodeId: 'logic.boolean',
|
||||
title: 'NOT y combinaciones',
|
||||
description: '¿Cuál es el resultado de NOT (TRUE AND FALSE)?',
|
||||
difficulty: 2,
|
||||
type: 'multiple-choice',
|
||||
hints: ['Primero evalúa TRUE AND FALSE, luego aplica NOT'],
|
||||
xpReward: 40,
|
||||
content: {
|
||||
type: 'multiple-choice',
|
||||
question: '¿Cuál es el resultado de NOT (TRUE AND FALSE)?',
|
||||
options: ['TRUE', 'FALSE'],
|
||||
correctIndex: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const binaryChallenges: Challenge[] = [
|
||||
mathChallenge('bin-01', 'logic.binary', 'Decimal a binario', '¿Cuánto es 13 en binario? (escribe el número decimal que forman los dígitos binarios, ej: 1101)', 1101, 2, ['13 = 8+4+1 = 1101₂'], 0,
|
||||
'El sistema binario usa solo dos dígitos: 0 y 1. Cada posición vale el doble que la anterior (de derecha a izquierda):\n\n... 16 8 4 2 1\n\nPara convertir decimal a binario, descompón en potencias de 2:\n• 13 = 8 + 4 + 1\n• 13 = 1×8 + 1×4 + 0×2 + 1×1\n• 13 en binario = 1101\n\nMétodo alternativo: divide entre 2 repetidamente y lee los restos de abajo arriba:\n• 13÷2 = 6 resto 1\n• 6÷2 = 3 resto 0\n• 3÷2 = 1 resto 1\n• 1÷2 = 0 resto 1\n→ 1101'),
|
||||
mathChallenge('bin-02', 'logic.binary', 'Binario a decimal', '¿Cuánto es 10110 en decimal?', 22, 2, ['1×16 + 0×8 + 1×4 + 1×2 + 0×1']),
|
||||
mathChallenge('bin-03', 'logic.binary', 'Suma binaria', '¿Cuánto es 1010 + 0110 en decimal?', 16, 2, ['1010=10, 0110=6, 10+6=16']),
|
||||
];
|
||||
|
||||
export const allChallenges: Challenge[] = [
|
||||
...arithmeticChallenges,
|
||||
...fractionChallenges,
|
||||
...decimalChallenges,
|
||||
...percentageChallenges,
|
||||
...primeChallenges,
|
||||
...gcdLcmChallenges,
|
||||
...variableChallenges,
|
||||
...equationChallenges,
|
||||
...linearSystemChallenges,
|
||||
...quadraticChallenges,
|
||||
...booleanChallenges,
|
||||
...binaryChallenges,
|
||||
];
|
||||
|
||||
export function getChallengeById(id: string): Challenge | undefined {
|
||||
return allChallenges.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
export function getChallengesForNode(nodeId: string): Challenge[] {
|
||||
return allChallenges.filter((c) => c.nodeId === nodeId);
|
||||
}
|
||||
256
src/data/skill-tree.ts
Normal file
256
src/data/skill-tree.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { SkillNode, SkillEdge } from '@/types/skill-tree';
|
||||
|
||||
// Layout constants
|
||||
const ROW_GAP = 180; // vertical spacing between rows
|
||||
const COL_GAP = 250; // horizontal spacing between nodes
|
||||
const CENTER = 300; // center x for single nodes
|
||||
|
||||
// Row helper: y position for each tier
|
||||
const row = (n: number) => n * ROW_GAP;
|
||||
|
||||
export const skillNodes: SkillNode[] = [
|
||||
// === ROW 0: Root ===
|
||||
{
|
||||
id: 'arithmetic.addition',
|
||||
discipline: 'mathematics',
|
||||
branch: 'arithmetic',
|
||||
title: 'Suma',
|
||||
description: 'Aprende a sumar números enteros',
|
||||
icon: '➕',
|
||||
position: { x: CENTER, y: row(0) },
|
||||
prerequisites: [],
|
||||
challenges: ['add-01', 'add-02', 'add-03', 'add-04'],
|
||||
difficulty: 1,
|
||||
},
|
||||
|
||||
// === ROW 1: Suma branches into Resta & Multiplicación ===
|
||||
{
|
||||
id: 'arithmetic.subtraction',
|
||||
discipline: 'mathematics',
|
||||
branch: 'arithmetic',
|
||||
title: 'Resta',
|
||||
description: 'Aprende a restar números enteros',
|
||||
icon: '➖',
|
||||
position: { x: CENTER - COL_GAP / 2, y: row(1) },
|
||||
prerequisites: ['arithmetic.addition'],
|
||||
challenges: ['sub-01', 'sub-02', 'sub-03'],
|
||||
difficulty: 1,
|
||||
},
|
||||
{
|
||||
id: 'arithmetic.multiplication',
|
||||
discipline: 'mathematics',
|
||||
branch: 'arithmetic',
|
||||
title: 'Multiplicación',
|
||||
description: 'Domina las tablas de multiplicar y más',
|
||||
icon: '✖️',
|
||||
position: { x: CENTER + COL_GAP / 2, y: row(1) },
|
||||
prerequisites: ['arithmetic.addition'],
|
||||
challenges: ['mul-01', 'mul-02', 'mul-03'],
|
||||
difficulty: 1,
|
||||
},
|
||||
|
||||
// === ROW 2: División (from Multiplicación) ===
|
||||
{
|
||||
id: 'arithmetic.division',
|
||||
discipline: 'mathematics',
|
||||
branch: 'arithmetic',
|
||||
title: 'División',
|
||||
description: 'Divide números enteros con y sin resto',
|
||||
icon: '➗',
|
||||
position: { x: CENTER + COL_GAP / 2, y: row(2) },
|
||||
prerequisites: ['arithmetic.multiplication'],
|
||||
challenges: ['div-01', 'div-02', 'div-03'],
|
||||
difficulty: 1,
|
||||
},
|
||||
|
||||
// === ROW 3: Branches from Resta+División & División ===
|
||||
{
|
||||
id: 'arithmetic.fractions',
|
||||
discipline: 'mathematics',
|
||||
branch: 'arithmetic',
|
||||
title: 'Fracciones',
|
||||
description: 'Opera con fracciones: suma, resta, multiplicación y división',
|
||||
icon: '½',
|
||||
position: { x: CENTER - COL_GAP, y: row(3) },
|
||||
prerequisites: ['arithmetic.subtraction', 'arithmetic.division'],
|
||||
challenges: ['frac-01', 'frac-02', 'frac-03'],
|
||||
difficulty: 2,
|
||||
},
|
||||
{
|
||||
id: 'arithmetic.decimals',
|
||||
discipline: 'mathematics',
|
||||
branch: 'arithmetic',
|
||||
title: 'Decimales',
|
||||
description: 'Trabaja con números decimales y redondeo',
|
||||
icon: '0.5',
|
||||
position: { x: CENTER, y: row(3) },
|
||||
prerequisites: ['arithmetic.division'],
|
||||
challenges: ['dec-01', 'dec-02', 'dec-03'],
|
||||
difficulty: 2,
|
||||
},
|
||||
{
|
||||
id: 'number-theory.gcd-lcm',
|
||||
discipline: 'mathematics',
|
||||
branch: 'number-theory',
|
||||
title: 'MCD y MCM',
|
||||
description: 'Máximo común divisor y mínimo común múltiplo',
|
||||
icon: '🔗',
|
||||
position: { x: CENTER + COL_GAP, y: row(3) },
|
||||
prerequisites: ['arithmetic.division'],
|
||||
challenges: ['gcd-01', 'gcd-02', 'gcd-03'],
|
||||
difficulty: 2,
|
||||
},
|
||||
|
||||
// === ROW 4: Porcentajes & Primos ===
|
||||
{
|
||||
id: 'arithmetic.percentages',
|
||||
discipline: 'mathematics',
|
||||
branch: 'arithmetic',
|
||||
title: 'Porcentajes',
|
||||
description: 'Calcula porcentajes, descuentos y aumentos',
|
||||
icon: '%',
|
||||
position: { x: CENTER - COL_GAP / 2, y: row(4) },
|
||||
prerequisites: ['arithmetic.fractions', 'arithmetic.decimals'],
|
||||
challenges: ['pct-01', 'pct-02', 'pct-03'],
|
||||
difficulty: 2,
|
||||
},
|
||||
{
|
||||
id: 'number-theory.primes',
|
||||
discipline: 'mathematics',
|
||||
branch: 'number-theory',
|
||||
title: 'Números Primos',
|
||||
description: 'Identifica primos y comprende la factorización',
|
||||
icon: '🔢',
|
||||
position: { x: CENTER + COL_GAP, y: row(4) },
|
||||
prerequisites: ['arithmetic.division'],
|
||||
challenges: ['prime-01', 'prime-02', 'prime-03'],
|
||||
difficulty: 2,
|
||||
},
|
||||
|
||||
// === ROW 5: Algebra starts & Logic starts ===
|
||||
{
|
||||
id: 'algebra.variables',
|
||||
discipline: 'mathematics',
|
||||
branch: 'algebra',
|
||||
title: 'Variables y Expresiones',
|
||||
description: 'Introduce variables y evalúa expresiones algebraicas',
|
||||
icon: '𝑥',
|
||||
position: { x: CENTER - COL_GAP / 2, y: row(5) },
|
||||
prerequisites: ['arithmetic.percentages'],
|
||||
challenges: ['var-01', 'var-02', 'var-03'],
|
||||
difficulty: 2,
|
||||
},
|
||||
{
|
||||
id: 'logic.boolean',
|
||||
discipline: 'logic',
|
||||
branch: 'logic',
|
||||
title: 'Lógica Booleana',
|
||||
description: 'AND, OR, NOT — las bases del pensamiento lógico',
|
||||
icon: '🧠',
|
||||
position: { x: CENTER + COL_GAP, y: row(5) },
|
||||
prerequisites: ['number-theory.primes'],
|
||||
challenges: ['bool-01', 'bool-02', 'bool-03'],
|
||||
difficulty: 2,
|
||||
},
|
||||
|
||||
// === ROW 6: Ecuaciones & Binario ===
|
||||
{
|
||||
id: 'algebra.equations',
|
||||
discipline: 'mathematics',
|
||||
branch: 'algebra',
|
||||
title: 'Ecuaciones Lineales',
|
||||
description: 'Resuelve ecuaciones de primer grado',
|
||||
icon: '⚖️',
|
||||
position: { x: CENTER - COL_GAP / 2, y: row(6) },
|
||||
prerequisites: ['algebra.variables'],
|
||||
challenges: ['eq-01', 'eq-02', 'eq-03', 'eq-04'],
|
||||
difficulty: 3,
|
||||
},
|
||||
{
|
||||
id: 'logic.binary',
|
||||
discipline: 'logic',
|
||||
branch: 'logic',
|
||||
title: 'Sistema Binario',
|
||||
description: 'Convierte entre decimal, binario y hexadecimal',
|
||||
icon: '0️⃣1️⃣',
|
||||
position: { x: CENTER + COL_GAP, y: row(6) },
|
||||
prerequisites: ['logic.boolean'],
|
||||
challenges: ['bin-01', 'bin-02', 'bin-03'],
|
||||
difficulty: 2,
|
||||
},
|
||||
|
||||
// === ROW 7: Algebra branches ===
|
||||
{
|
||||
id: 'algebra.linear-systems',
|
||||
discipline: 'mathematics',
|
||||
branch: 'algebra',
|
||||
title: 'Sistemas Lineales',
|
||||
description: 'Resuelve sistemas de dos ecuaciones con dos incógnitas',
|
||||
icon: '📐',
|
||||
position: { x: CENTER - COL_GAP, y: row(7) },
|
||||
prerequisites: ['algebra.equations'],
|
||||
challenges: ['sys-01', 'sys-02', 'sys-03'],
|
||||
difficulty: 3,
|
||||
},
|
||||
{
|
||||
id: 'algebra.quadratics',
|
||||
discipline: 'mathematics',
|
||||
branch: 'algebra',
|
||||
title: 'Ecuaciones Cuadráticas',
|
||||
description: 'Resuelve ecuaciones de segundo grado con la fórmula general',
|
||||
icon: '📈',
|
||||
position: { x: CENTER, y: row(7) },
|
||||
prerequisites: ['algebra.equations'],
|
||||
challenges: ['quad-01', 'quad-02', 'quad-03'],
|
||||
difficulty: 3,
|
||||
},
|
||||
];
|
||||
|
||||
export const skillEdges: SkillEdge[] = skillNodes
|
||||
.flatMap((node) =>
|
||||
node.prerequisites.map((prereq) => ({
|
||||
from: prereq,
|
||||
to: node.id,
|
||||
type: 'hard' as const,
|
||||
}))
|
||||
);
|
||||
|
||||
export function getNodeById(id: string): SkillNode | undefined {
|
||||
return skillNodes.find((n) => n.id === id);
|
||||
}
|
||||
|
||||
export function getAvailableNodes(completedNodeIds: string[]): string[] {
|
||||
return skillNodes
|
||||
.filter(
|
||||
(node) =>
|
||||
!completedNodeIds.includes(node.id) &&
|
||||
node.prerequisites.every((prereq) => completedNodeIds.includes(prereq))
|
||||
)
|
||||
.map((n) => n.id);
|
||||
}
|
||||
|
||||
export function getNodeStatus(
|
||||
nodeId: string,
|
||||
completedNodeIds: string[],
|
||||
completedChallengeIds: string[]
|
||||
): 'locked' | 'available' | 'in-progress' | 'completed' {
|
||||
const node = getNodeById(nodeId);
|
||||
if (!node) return 'locked';
|
||||
|
||||
const allChallengesCompleted = node.challenges.every((c) =>
|
||||
completedChallengeIds.includes(`${nodeId}/${c}`)
|
||||
);
|
||||
if (allChallengesCompleted) return 'completed';
|
||||
|
||||
const someChallengesCompleted = node.challenges.some((c) =>
|
||||
completedChallengeIds.includes(`${nodeId}/${c}`)
|
||||
);
|
||||
if (someChallengesCompleted) return 'in-progress';
|
||||
|
||||
const prerequisitesMet = node.prerequisites.every((prereq) =>
|
||||
completedNodeIds.includes(prereq)
|
||||
);
|
||||
if (prerequisitesMet || node.prerequisites.length === 0) return 'available';
|
||||
|
||||
return 'locked';
|
||||
}
|
||||
55
src/lib/challenge-engine/verifier.ts
Normal file
55
src/lib/challenge-engine/verifier.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Challenge, VerificationResult } from '@/types/challenge';
|
||||
|
||||
export function verifyAnswer(
|
||||
challenge: Challenge,
|
||||
userAnswer: string | number
|
||||
): VerificationResult {
|
||||
const { content } = challenge;
|
||||
|
||||
switch (content.type) {
|
||||
case 'math-input':
|
||||
return verifyMathInput(content, userAnswer, challenge.xpReward);
|
||||
case 'multiple-choice':
|
||||
return verifyMultipleChoice(content, userAnswer, challenge.xpReward);
|
||||
default:
|
||||
return { correct: false, message: 'Tipo de reto no soportado', xpEarned: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function verifyMathInput(
|
||||
content: { answer: { type: string; value: number | string; tolerance?: number } },
|
||||
userAnswer: string | number,
|
||||
xpReward: number
|
||||
): VerificationResult {
|
||||
const numericAnswer = typeof userAnswer === 'string' ? parseFloat(userAnswer) : userAnswer;
|
||||
|
||||
if (isNaN(numericAnswer)) {
|
||||
return { correct: false, message: 'Por favor ingresa un número válido', xpEarned: 0 };
|
||||
}
|
||||
|
||||
const expected = typeof content.answer.value === 'string'
|
||||
? parseFloat(content.answer.value)
|
||||
: content.answer.value;
|
||||
|
||||
const tolerance = content.answer.tolerance ?? 0;
|
||||
|
||||
if (Math.abs(numericAnswer - expected) <= tolerance) {
|
||||
return { correct: true, message: '¡Correcto! 🎉', xpEarned: xpReward };
|
||||
}
|
||||
|
||||
return { correct: false, message: 'Incorrecto. Inténtalo de nuevo.', xpEarned: 0 };
|
||||
}
|
||||
|
||||
function verifyMultipleChoice(
|
||||
content: { correctIndex: number },
|
||||
userAnswer: string | number,
|
||||
xpReward: number
|
||||
): VerificationResult {
|
||||
const selectedIndex = typeof userAnswer === 'string' ? parseInt(userAnswer) : userAnswer;
|
||||
|
||||
if (selectedIndex === content.correctIndex) {
|
||||
return { correct: true, message: '¡Correcto! 🎉', xpEarned: xpReward };
|
||||
}
|
||||
|
||||
return { correct: false, message: 'Incorrecto. Inténtalo de nuevo.', xpEarned: 0 };
|
||||
}
|
||||
182
src/stores/useProgressStore.ts
Normal file
182
src/stores/useProgressStore.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { UserProgress, levelFromXP } from '@/types/user';
|
||||
import { getAvailableNodes, skillNodes } from '@/data/skill-tree';
|
||||
import { getChallengesForNode } from '@/data/challenges/math';
|
||||
|
||||
interface ProgressStore extends UserProgress {
|
||||
completeChallenge: (challengeId: string, timeSpent: number) => void;
|
||||
getNodeStatus: (nodeId: string) => 'locked' | 'available' | 'in-progress' | 'completed';
|
||||
getCompletedNodeIds: () => string[];
|
||||
updateStreak: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState: UserProgress = {
|
||||
completedChallenges: {},
|
||||
unlockedNodes: [],
|
||||
totalXP: 0,
|
||||
level: 1,
|
||||
currentStreak: 0,
|
||||
longestStreak: 0,
|
||||
lastActiveDate: '',
|
||||
achievements: [],
|
||||
};
|
||||
|
||||
export const useProgressStore = create<ProgressStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
completeChallenge: (challengeId: string, timeSpent: number) => {
|
||||
const state = get();
|
||||
if (state.completedChallenges[challengeId]) return;
|
||||
|
||||
// Find challenge xp
|
||||
const allChallenges = skillNodes.flatMap((n) =>
|
||||
getChallengesForNode(n.id)
|
||||
);
|
||||
const challenge = allChallenges.find((c) => c.id === challengeId);
|
||||
const xpReward = challenge?.xpReward ?? 20;
|
||||
|
||||
const newCompleted = {
|
||||
...state.completedChallenges,
|
||||
[challengeId]: {
|
||||
bestScore: 100,
|
||||
completedAt: new Date().toISOString(),
|
||||
timeSpent,
|
||||
attempts: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const newXP = state.totalXP + xpReward;
|
||||
|
||||
// Check which nodes are now completed
|
||||
const completedNodeIds: string[] = [];
|
||||
for (const node of skillNodes) {
|
||||
const allDone = node.challenges.every(
|
||||
(cId) => newCompleted[`${node.id}/${cId}`]
|
||||
);
|
||||
if (allDone) completedNodeIds.push(node.id);
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
let streak = state.currentStreak;
|
||||
let longest = state.longestStreak;
|
||||
|
||||
if (state.lastActiveDate !== today) {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
||||
|
||||
if (state.lastActiveDate === yesterdayStr) {
|
||||
streak += 1;
|
||||
} else if (state.lastActiveDate !== today) {
|
||||
streak = 1;
|
||||
}
|
||||
longest = Math.max(longest, streak);
|
||||
}
|
||||
|
||||
// Check achievements
|
||||
const newAchievements = [...state.achievements];
|
||||
const completedCount = Object.keys(newCompleted).length;
|
||||
if (completedCount >= 1 && !newAchievements.includes('first-step')) {
|
||||
newAchievements.push('first-step');
|
||||
}
|
||||
if (completedCount >= 10 && !newAchievements.includes('getting-started')) {
|
||||
newAchievements.push('getting-started');
|
||||
}
|
||||
if (completedNodeIds.length >= 3 && !newAchievements.includes('explorer')) {
|
||||
newAchievements.push('explorer');
|
||||
}
|
||||
if (completedNodeIds.length >= 5 && !newAchievements.includes('scholar')) {
|
||||
newAchievements.push('scholar');
|
||||
}
|
||||
if (streak >= 3 && !newAchievements.includes('consistent')) {
|
||||
newAchievements.push('consistent');
|
||||
}
|
||||
if (streak >= 7 && !newAchievements.includes('dedicated')) {
|
||||
newAchievements.push('dedicated');
|
||||
}
|
||||
// Cross-branch: check if completed nodes span multiple branches
|
||||
const branches = new Set(
|
||||
completedNodeIds
|
||||
.map((id) => skillNodes.find((n) => n.id === id)?.branch)
|
||||
.filter(Boolean)
|
||||
);
|
||||
if (branches.size >= 3 && !newAchievements.includes('polymath')) {
|
||||
newAchievements.push('polymath');
|
||||
}
|
||||
|
||||
set({
|
||||
completedChallenges: newCompleted,
|
||||
unlockedNodes: completedNodeIds,
|
||||
totalXP: newXP,
|
||||
level: levelFromXP(newXP),
|
||||
currentStreak: streak,
|
||||
longestStreak: longest,
|
||||
lastActiveDate: today,
|
||||
achievements: newAchievements,
|
||||
});
|
||||
},
|
||||
|
||||
getCompletedNodeIds: () => {
|
||||
const state = get();
|
||||
const completedNodeIds: string[] = [];
|
||||
for (const node of skillNodes) {
|
||||
const allDone = node.challenges.every(
|
||||
(cId) => state.completedChallenges[`${node.id}/${cId}`]
|
||||
);
|
||||
if (allDone) completedNodeIds.push(node.id);
|
||||
}
|
||||
return completedNodeIds;
|
||||
},
|
||||
|
||||
getNodeStatus: (nodeId: string) => {
|
||||
const state = get();
|
||||
const node = skillNodes.find((n) => n.id === nodeId);
|
||||
if (!node) return 'locked';
|
||||
|
||||
const completedNodeIds = get().getCompletedNodeIds();
|
||||
|
||||
const allDone = node.challenges.every(
|
||||
(cId) => state.completedChallenges[`${node.id}/${cId}`]
|
||||
);
|
||||
if (allDone) return 'completed';
|
||||
|
||||
const someDone = node.challenges.some(
|
||||
(cId) => state.completedChallenges[`${node.id}/${cId}`]
|
||||
);
|
||||
if (someDone) return 'in-progress';
|
||||
|
||||
const prereqsMet = node.prerequisites.every((p) =>
|
||||
completedNodeIds.includes(p)
|
||||
);
|
||||
if (prereqsMet || node.prerequisites.length === 0) return 'available';
|
||||
|
||||
return 'locked';
|
||||
},
|
||||
|
||||
updateStreak: () => {
|
||||
const state = get();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
if (state.lastActiveDate === today) return;
|
||||
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
||||
|
||||
if (state.lastActiveDate !== yesterdayStr) {
|
||||
set({ currentStreak: 0 });
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
{
|
||||
name: 'project-math-progress',
|
||||
}
|
||||
)
|
||||
);
|
||||
47
src/types/challenge.ts
Normal file
47
src/types/challenge.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export type WorkbenchType =
|
||||
| 'math-input'
|
||||
| 'multiple-choice'
|
||||
| 'code-editor'
|
||||
| 'circuit-builder'
|
||||
| 'physics-sim'
|
||||
| 'signal-playground'
|
||||
| 'graph-plotter';
|
||||
|
||||
export interface MathInputContent {
|
||||
type: 'math-input';
|
||||
problem: string;
|
||||
answer: {
|
||||
type: 'numeric' | 'expression';
|
||||
value: number | string;
|
||||
tolerance?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MultipleChoiceContent {
|
||||
type: 'multiple-choice';
|
||||
question: string;
|
||||
options: string[];
|
||||
correctIndex: number;
|
||||
}
|
||||
|
||||
export type ChallengeContent = MathInputContent | MultipleChoiceContent;
|
||||
|
||||
export interface Challenge {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
difficulty: 1 | 2 | 3 | 4 | 5;
|
||||
type: WorkbenchType;
|
||||
hints: string[];
|
||||
xpReward: number;
|
||||
content: ChallengeContent;
|
||||
/** Explanation shown before the challenge to teach the concept */
|
||||
explanation?: string;
|
||||
}
|
||||
|
||||
export interface VerificationResult {
|
||||
correct: boolean;
|
||||
message: string;
|
||||
xpEarned: number;
|
||||
}
|
||||
30
src/types/skill-tree.ts
Normal file
30
src/types/skill-tree.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type Discipline =
|
||||
| 'mathematics'
|
||||
| 'logic'
|
||||
| 'programming'
|
||||
| 'physics'
|
||||
| 'electronics'
|
||||
| 'cryptography'
|
||||
| 'signal-processing'
|
||||
| 'statistics';
|
||||
|
||||
export interface SkillNode {
|
||||
id: string;
|
||||
discipline: Discipline;
|
||||
branch: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
position: { x: number; y: number };
|
||||
prerequisites: string[];
|
||||
challenges: string[];
|
||||
difficulty: 1 | 2 | 3 | 4 | 5;
|
||||
}
|
||||
|
||||
export interface SkillEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
type: 'hard' | 'soft';
|
||||
}
|
||||
|
||||
export type NodeStatus = 'locked' | 'available' | 'in-progress' | 'completed' | 'mastered';
|
||||
53
src/types/user.ts
Normal file
53
src/types/user.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export interface ChallengeCompletion {
|
||||
bestScore: number;
|
||||
completedAt: string;
|
||||
timeSpent: number;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export interface UserProgress {
|
||||
completedChallenges: Record<string, ChallengeCompletion>;
|
||||
unlockedNodes: string[];
|
||||
totalXP: number;
|
||||
level: number;
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
lastActiveDate: string;
|
||||
achievements: string[];
|
||||
}
|
||||
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
condition: (progress: UserProgress) => boolean;
|
||||
}
|
||||
|
||||
export function xpForLevel(level: number): number {
|
||||
return Math.floor(100 * Math.pow(1.5, level - 1));
|
||||
}
|
||||
|
||||
export function levelFromXP(totalXP: number): number {
|
||||
let level = 1;
|
||||
let xpNeeded = 100;
|
||||
let accumulated = 0;
|
||||
while (accumulated + xpNeeded <= totalXP) {
|
||||
accumulated += xpNeeded;
|
||||
level++;
|
||||
xpNeeded = Math.floor(100 * Math.pow(1.5, level - 1));
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
export function xpProgressInLevel(totalXP: number): { current: number; needed: number } {
|
||||
let level = 1;
|
||||
let xpNeeded = 100;
|
||||
let accumulated = 0;
|
||||
while (accumulated + xpNeeded <= totalXP) {
|
||||
accumulated += xpNeeded;
|
||||
level++;
|
||||
xpNeeded = Math.floor(100 * Math.pow(1.5, level - 1));
|
||||
}
|
||||
return { current: totalXP - accumulated, needed: xpNeeded };
|
||||
}
|
||||
Reference in New Issue
Block a user