From f9f74d3f19102b26903eb031919cc2c81a7328fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Luis=20Monta=C3=B1es?= Date: Thu, 26 Mar 2026 01:53:45 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20MathTree=20MVP=20=E2=80=94?= =?UTF-8?q?=20skill=20tree,=20workbench,=20and=20progress=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- package-lock.json | 16 + package.json | 1 + src/app/(main)/layout.tsx | 64 +++ src/app/(main)/profile/page.tsx | 132 ++++++ src/app/(main)/skill-tree/page.tsx | 19 + .../(main)/workbench/[challengeId]/page.tsx | 21 + src/app/globals.css | 65 +++ src/app/layout.tsx | 13 +- src/app/page.tsx | 64 +-- src/components/common/StreakBadge.tsx | 17 + src/components/common/XPBar.tsx | 26 ++ src/components/skill-tree/SkillNode.tsx | 54 +++ src/components/skill-tree/SkillNodeDetail.tsx | 135 ++++++ src/components/skill-tree/SkillTreeCanvas.tsx | 154 +++++++ src/components/ui/badge.tsx | 52 +++ src/components/ui/card.tsx | 103 +++++ src/components/ui/dialog.tsx | 160 +++++++ src/components/ui/progress.tsx | 83 ++++ src/components/ui/scroll-area.tsx | 55 +++ src/components/ui/separator.tsx | 25 + src/components/ui/tooltip.tsx | 66 +++ src/components/workbench/Scratchpad.tsx | 366 +++++++++++++++ src/components/workbench/WorkbenchShell.tsx | 433 ++++++++++++++++++ .../workbench/modules/MathInput.tsx | 102 +++++ .../workbench/modules/MultipleChoice.tsx | 45 ++ src/data/challenges/math.ts | 204 +++++++++ src/data/skill-tree.ts | 256 +++++++++++ src/lib/challenge-engine/verifier.ts | 55 +++ src/stores/useProgressStore.ts | 182 ++++++++ src/types/challenge.ts | 47 ++ src/types/skill-tree.ts | 30 ++ src/types/user.ts | 53 +++ 32 files changed, 3031 insertions(+), 67 deletions(-) create mode 100644 src/app/(main)/layout.tsx create mode 100644 src/app/(main)/profile/page.tsx create mode 100644 src/app/(main)/skill-tree/page.tsx create mode 100644 src/app/(main)/workbench/[challengeId]/page.tsx create mode 100644 src/components/common/StreakBadge.tsx create mode 100644 src/components/common/XPBar.tsx create mode 100644 src/components/skill-tree/SkillNode.tsx create mode 100644 src/components/skill-tree/SkillNodeDetail.tsx create mode 100644 src/components/skill-tree/SkillTreeCanvas.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/components/workbench/Scratchpad.tsx create mode 100644 src/components/workbench/WorkbenchShell.tsx create mode 100644 src/components/workbench/modules/MathInput.tsx create mode 100644 src/components/workbench/modules/MultipleChoice.tsx create mode 100644 src/data/challenges/math.ts create mode 100644 src/data/skill-tree.ts create mode 100644 src/lib/challenge-engine/verifier.ts create mode 100644 src/stores/useProgressStore.ts create mode 100644 src/types/challenge.ts create mode 100644 src/types/skill-tree.ts create mode 100644 src/types/user.ts diff --git a/package-lock.json b/package-lock.json index 3a86dfa..2aa879b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a9ff3a3..6c9baab 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx new file mode 100644 index 0000000..159bbd6 --- /dev/null +++ b/src/app/(main)/layout.tsx @@ -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 ( +
+ {/* Sidebar */} + + + {/* Main content */} +
{children}
+
+ ); +} diff --git a/src/app/(main)/profile/page.tsx b/src/app/(main)/profile/page.tsx new file mode 100644 index 0000000..22e2253 --- /dev/null +++ b/src/app/(main)/profile/page.tsx @@ -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 = { + '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 ( +
+
+

Tu Perfil

+
+ +
+ {/* Level & XP */} + +
+
+ {progress.level} +
+
+

Nivel {progress.level}

+

{progress.totalXP} XP total

+
+ + + {current}/{needed} XP + +
+
+
+
+ + {/* Stats grid */} +
+ + +
{completedChallengeCount}
+
de {totalChallenges} retos
+
+ + +
{completedNodeIds.length}
+
de {totalNodes} nodos
+
+ + +
{progress.currentStreak}
+
racha actual
+
+ + +
{progress.longestStreak}
+
mejor racha
+
+
+ + {/* Achievements */} + +
+ +

Logros

+ + {progress.achievements.length}/{Object.keys(achievementMeta).length} + +
+
+ {Object.entries(achievementMeta).map(([id, meta]) => { + const unlocked = progress.achievements.includes(id); + return ( +
+ {meta.icon} +
+

{meta.title}

+

{meta.description}

+
+
+ ); + })} +
+
+ + {/* Reset */} +
+ +
+
+
+ ); +} diff --git a/src/app/(main)/skill-tree/page.tsx b/src/app/(main)/skill-tree/page.tsx new file mode 100644 index 0000000..57a346e --- /dev/null +++ b/src/app/(main)/skill-tree/page.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { SkillTreeCanvas } from '@/components/skill-tree/SkillTreeCanvas'; + +export default function SkillTreePage() { + return ( +
+
+

Árbol de Habilidades

+

+ Haz clic en un nodo para ver sus retos +

+
+
+ +
+
+ ); +} diff --git a/src/app/(main)/workbench/[challengeId]/page.tsx b/src/app/(main)/workbench/[challengeId]/page.tsx new file mode 100644 index 0000000..a18eb31 --- /dev/null +++ b/src/app/(main)/workbench/[challengeId]/page.tsx @@ -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 ; +} diff --git a/src/app/globals.css b/src/app/globals.css index c56032b..e0f3510 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 976eb90..89090c1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - {children} + + {children} + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f36f7c..249d585 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,5 @@ -import Image from "next/image"; +import { redirect } from 'next/navigation'; export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); + redirect('/skill-tree'); } diff --git a/src/components/common/StreakBadge.tsx b/src/components/common/StreakBadge.tsx new file mode 100644 index 0000000..6f1701b --- /dev/null +++ b/src/components/common/StreakBadge.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { useProgressStore } from '@/stores/useProgressStore'; +import { Flame } from 'lucide-react'; + +export function StreakBadge() { + const { currentStreak } = useProgressStore(); + + return ( +
+ 0 ? 'text-orange-500' : 'text-muted-foreground'}`} /> + 0 ? 'text-orange-500 font-medium' : 'text-muted-foreground'}> + {currentStreak} + +
+ ); +} diff --git a/src/components/common/XPBar.tsx b/src/components/common/XPBar.tsx new file mode 100644 index 0000000..d5696d4 --- /dev/null +++ b/src/components/common/XPBar.tsx @@ -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 ( +
+
+
+ {level} +
+
+
{totalXP} XP
+
{current}/{needed}
+
+
+ +
+ ); +} diff --git a/src/components/skill-tree/SkillNode.tsx b/src/components/skill-tree/SkillNode.tsx new file mode 100644 index 0000000..9b18856 --- /dev/null +++ b/src/components/skill-tree/SkillNode.tsx @@ -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; +type SkillNodeType = Node; + +const statusStyles: Record = { + 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 = { + 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) { + const status = data.status; + const badge = statusBadge[status]; + + return ( +
+ + +
+ {status === 'locked' ? '' : data.icon} + {status === 'locked' && } + {data.title} +
+ + {badge && ( +
+ {badge.label} +
+ )} + + +
+ ); +}); diff --git a/src/components/skill-tree/SkillNodeDetail.tsx b/src/components/skill-tree/SkillNodeDetail.tsx new file mode 100644 index 0000000..6a92fd6 --- /dev/null +++ b/src/components/skill-tree/SkillNodeDetail.tsx @@ -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 ( +
+ {/* Header */} +
+
+
+ {node.icon} +

{node.title}

+
+

{node.description}

+
+ + {node.discipline} + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+
+ +
+ + {/* Progress */} +
+
+ Progreso + + {completedCount}/{challenges.length} retos + +
+
+
0 ? (completedCount / challenges.length) * 100 : 0}%` }} + /> +
+
+ + {/* Challenges list */} + +
+ {status === 'locked' ? ( +
+ +

Nodo bloqueado

+

+ Completa los prerrequisitos para desbloquear +

+
+ ) : ( + challenges.map((challenge, index) => { + const isCompleted = !!completedChallenges[challenge.id]; + return ( + { + router.push(`/workbench/${encodeURIComponent(challenge.id)}`); + }} + > +
+
+
+ {isCompleted ? ( + + ) : ( + index + 1 + )} +
+
+

{challenge.title}

+

+ +{challenge.xpReward} XP +

+
+
+ {!isCompleted && ( + + )} +
+
+ ); + }) + )} +
+
+
+ ); +} diff --git a/src/components/skill-tree/SkillTreeCanvas.tsx b/src/components/skill-tree/SkillTreeCanvas.tsx new file mode 100644 index 0000000..6a87025 --- /dev/null +++ b/src/components/skill-tree/SkillTreeCanvas.tsx @@ -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(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 ( +
+ Cargando árbol... +
+ ); + } + + return ( +
+ + + + + + {selectedNodeId && ( + setSelectedNodeId(null)} + /> + )} +
+ ); +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..b20959d --- /dev/null +++ b/src/components/ui/badge.tsx @@ -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) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..40cac5f --- /dev/null +++ b/src/components/ui/card.tsx @@ -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 ( +
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 ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..014f5aa --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -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 +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogOverlay({ + className, + ...props +}: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + }> + Close + + )} +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..986f346 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -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 ( + + {children} + + + + + ) +} + +function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) { + return ( + + ) +} + +function ProgressIndicator({ + className, + ...props +}: ProgressPrimitive.Indicator.Props) { + return ( + + ) +} + +function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) { + return ( + + ) +} + +function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) { + return ( + + ) +} + +export { + Progress, + ProgressTrack, + ProgressIndicator, + ProgressLabel, + ProgressValue, +} diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..84c1e9f --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -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 ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: ScrollAreaPrimitive.Scrollbar.Props) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..6e1369e --- /dev/null +++ b/src/components/ui/separator.tsx @@ -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 ( + + ) +} + +export { Separator } diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..69e8a82 --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -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 ( + + ) +} + +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { + return +} + +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return +} + +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 ( + + + + {children} + + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/src/components/workbench/Scratchpad.tsx b/src/components/workbench/Scratchpad.tsx new file mode 100644 index 0000000..ddfdcb5 --- /dev/null +++ b/src/components/workbench/Scratchpad.tsx @@ -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(null); + const containerRef = useRef(null); + const [tool, setTool] = useState('pen'); + const [color, setColor] = useState('#ffffff'); + const [strokeSize, setStrokeSize] = useState(2); + const [isDrawing, setIsDrawing] = useState(false); + const [textBoxes, setTextBoxes] = useState([]); + 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) => { + 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) => { + 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 ( +
+ {/* Toolbar */} +
+ + + + +
+ + {/* Colors */} + {COLORS.map((c) => ( + + ))} + +
+ + +
+ + {/* Canvas area */} +
+ + + {/* Text boxes */} + {textBoxes.map((box) => ( +
+ {/* Drag handle */} +
{ + e.stopPropagation(); + setDragging({ id: box.id, offsetX: e.nativeEvent.offsetX, offsetY: e.nativeEvent.offsetY + 20 }); + }} + > +
+
+
+
+
+ +
+ +