feat: complete pragativeer v2 frontend and backend, docker setup, and cbt engine
This commit is contained in:
138
src/components/AI/AITutorChat.tsx
Normal file
138
src/components/AI/AITutorChat.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Send, Bot, User, Minimize2, Maximize2, X } from "lucide-react"
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import 'katex/dist/katex.min.css' // Import Katex CSS for math rendering
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'ai';
|
||||
content: string;
|
||||
}
|
||||
|
||||
const INITIAL_MESSAGES: Message[] = [
|
||||
{
|
||||
id: '1',
|
||||
role: 'ai',
|
||||
content: "Hi! I noticed you struggled with **Rotational Dynamics** in the last test. Let's look at a similar problem. \n\nA solid sphere rolls without slipping. The kinetic energy is $E = \\frac{1}{2}mv^2 + \\frac{1}{2}I\\omega^2$. \n\nWhat fraction of total kinetic energy is rotational?"
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: 'user',
|
||||
content: "I think it is $\\frac{2}{7}$?"
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
role: 'ai',
|
||||
content: "Excellent! Yes, because $I = \\frac{2}{5}mr^2$, the rotational kinetic energy is:\n\n$$ K_{rot} = \\frac{1}{2}(\\frac{2}{5}mr^2)(\\frac{v}{r})^2 = \\frac{1}{5}mv^2 $$\n\nTotal $K = \\frac{1}{2}mv^2 + \\frac{1}{5}mv^2 = \\frac{7}{10}mv^2$.\n\nSo, $\\frac{K_{rot}}{K_{total}} = \\frac{2/10}{7/10} = \\frac{2}{7}$. You nailed it!"
|
||||
}
|
||||
];
|
||||
|
||||
export function AITutorChat() {
|
||||
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
|
||||
const [input, setInput] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim()) return;
|
||||
const newMessage: Message = { id: Date.now().toString(), role: 'user', content: input };
|
||||
setMessages([...messages, newMessage]);
|
||||
setInput('');
|
||||
|
||||
// Mock AI response
|
||||
setTimeout(() => {
|
||||
setMessages(prev => [...prev, {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'ai',
|
||||
content: "That's an interesting point! Let's explore the math behind it: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$"
|
||||
}]);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-6 right-6 h-14 w-14 rounded-full shadow-[0_0_20px_-5px_var(--color-primary)] flex items-center justify-center z-50 bg-[#00F0FF] text-background hover:bg-[#00F0FF]/90 transition-transform hover:scale-105"
|
||||
>
|
||||
<Bot size={28} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`fixed right-0 md:right-6 bottom-0 md:bottom-6 border-border bg-background/95 backdrop-blur-xl shadow-2xl z-50 flex flex-col overflow-hidden transition-all duration-300 ease-in-out
|
||||
${isExpanded ? 'w-full md:w-[600px] h-full md:h-[80vh] rounded-none md:rounded-2xl' : 'w-full md:w-[400px] h-[600px] rounded-t-2xl md:rounded-2xl'}
|
||||
`}>
|
||||
<CardHeader className="p-4 border-b border-border bg-secondary/30 flex flex-row items-center justify-between space-y-0 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/20 text-primary rounded-lg border border-primary/30">
|
||||
<Bot size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-bold text-foreground">AI Coach</CardTitle>
|
||||
<p className="text-xs font-medium text-green-500 flex items-center gap-1">
|
||||
<span className="size-2 bg-green-500 rounded-full animate-pulse"></span> Online
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground" onClick={() => setIsExpanded(!isExpanded)}>
|
||||
{isExpanded ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive" onClick={() => setIsOpen(false)}>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 p-4 overflow-y-auto space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex gap-3 max-w-[90%] ${msg.role === 'user' ? 'ml-auto flex-row-reverse' : 'mr-auto'}`}>
|
||||
<div className={`shrink-0 h-8 w-8 rounded-full flex items-center justify-center border
|
||||
${msg.role === 'user' ? 'bg-background text-foreground border-border' : 'bg-primary text-primary-foreground border-primary'}
|
||||
`}>
|
||||
{msg.role === 'user' ? <User size={16} /> : <Bot size={16} />}
|
||||
</div>
|
||||
|
||||
{/* Markdown Container with Overflow Handling */}
|
||||
<div className={`px-4 py-3 rounded-2xl overflow-x-auto
|
||||
${msg.role === 'user' ? 'bg-secondary/50 border border-border text-foreground rounded-tr-sm' : 'bg-primary/10 border border-primary/20 text-foreground rounded-tl-sm'}
|
||||
`}>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none
|
||||
prose-p:leading-relaxed prose-pre:bg-secondary prose-pre:border prose-pre:border-border
|
||||
[&_.katex-display]:overflow-x-auto [&_.katex-display]:overflow-y-hidden [&_.katex-display]:pb-2 [&_.katex-display]:scrollbar-thin
|
||||
[&_.katex]:text-[1.1em]
|
||||
">
|
||||
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
||||
{msg.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="p-4 border-t border-border bg-background shrink-0">
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }} className="flex w-full items-center gap-2">
|
||||
<Input
|
||||
placeholder="Ask your AI Coach..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className="flex-1 bg-secondary/30 border-border focus-visible:ring-primary h-12 rounded-xl"
|
||||
/>
|
||||
<Button type="submit" size="icon" className="h-12 w-12 rounded-xl shrink-0 shadow-sm" disabled={!input.trim()}>
|
||||
<Send size={18} className="ml-1" />
|
||||
</Button>
|
||||
</form>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
103
src/components/AI/AdaptiveStudyPlan.tsx
Normal file
103
src/components/AI/AdaptiveStudyPlan.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CheckCircle2, Clock, SkipForward, BrainCircuit } from "lucide-react"
|
||||
|
||||
type PlanState = 'PENDING' | 'DONE' | 'SKIPPED';
|
||||
|
||||
interface PlanItem {
|
||||
id: string;
|
||||
title: string;
|
||||
duration: string;
|
||||
type: string;
|
||||
status: PlanState;
|
||||
}
|
||||
|
||||
const INITIAL_PLAN: PlanItem[] = [
|
||||
{ id: '1', title: 'Revise Thermodynamics Concepts', duration: '45 min', type: 'Theory', status: 'PENDING' },
|
||||
{ id: '2', title: 'Solve 15 Rotational Dynamics DPPs', duration: '30 min', type: 'Practice', status: 'PENDING' },
|
||||
{ id: '3', title: 'Review Mistakes in Optics (T-3)', duration: '20 min', type: 'Review', status: 'PENDING' },
|
||||
];
|
||||
|
||||
export function AdaptiveStudyPlan() {
|
||||
const [items, setItems] = useState<PlanItem[]>(INITIAL_PLAN);
|
||||
|
||||
const updateStatus = (id: string, newStatus: PlanState) => {
|
||||
setItems(items.map(item => item.id === id ? { ...item, status: newStatus } : item));
|
||||
};
|
||||
|
||||
const totalTime = items.reduce((acc, curr) => {
|
||||
if (curr.status === 'DONE' || curr.status === 'PENDING') {
|
||||
return acc + parseInt(curr.duration);
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Card className="border-border bg-background shadow-sm h-full flex flex-col">
|
||||
<CardHeader className="pb-4 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<BrainCircuit className="text-primary" /> Adaptive Study Plan
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 bg-secondary/50 px-3 py-1 rounded-full text-xs font-bold text-foreground">
|
||||
<Clock size={14} className="text-primary" /> Today: {totalTime} min
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Dynamically generated based on your weak areas.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-6">
|
||||
<div className="relative space-y-6 before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-border before:to-transparent">
|
||||
{items.map((item, index) => {
|
||||
const isDone = item.status === 'DONE';
|
||||
const isSkipped = item.status === 'SKIPPED';
|
||||
|
||||
return (
|
||||
<div key={item.id} className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group is-active">
|
||||
{/* Timeline Icon */}
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-full border-4 border-background shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2 shadow-sm z-10 transition-colors
|
||||
${isDone ? 'bg-green-500 text-white' : isSkipped ? 'bg-muted border-muted text-muted-foreground' : 'bg-primary/20 text-primary border-primary/30'}
|
||||
`}>
|
||||
{isDone ? <CheckCircle2 size={18} /> : isSkipped ? <SkipForward size={18} /> : <div className="size-2.5 rounded-full bg-primary animate-pulse" />}
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] p-4 rounded-xl border border-border bg-background shadow-sm transition-all hover:shadow-md">
|
||||
<div className={`flex items-center justify-between mb-1 ${isSkipped ? 'opacity-50' : ''}`}>
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider text-muted-foreground bg-secondary/50 px-2 py-0.5 rounded-md">
|
||||
{item.type}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-primary">{item.duration}</span>
|
||||
</div>
|
||||
<h4 className={`font-bold text-sm md:text-base leading-tight mb-3 transition-colors ${isDone ? 'line-through text-muted-foreground' : isSkipped ? 'text-muted-foreground' : 'text-foreground'}`}>
|
||||
{item.title}
|
||||
</h4>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
{item.status === 'PENDING' ? (
|
||||
<>
|
||||
<Button size="sm" onClick={() => updateStatus(item.id, 'DONE')} className="h-8 text-xs font-bold flex-1 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white border border-green-500/20">
|
||||
Mark Done
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => updateStatus(item.id, 'SKIPPED')} className="h-8 text-xs font-bold px-3 hover:bg-destructive/10 hover:text-destructive">
|
||||
Skip
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full flex items-center justify-center py-1 bg-secondary/50 rounded-lg text-xs font-bold text-muted-foreground">
|
||||
{isDone ? 'Completed' : 'Rescheduled'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
84
src/components/AI/CollegePredictorCard.tsx
Normal file
84
src/components/AI/CollegePredictorCard.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { GraduationCap, MapPin, RefreshCw } from "lucide-react"
|
||||
|
||||
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
||||
|
||||
export function CollegePredictorCard() {
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchReadiness() {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/api/ai-coach/readiness/${USER_ID}`)
|
||||
if (!res.ok) throw new Error("Failed to fetch")
|
||||
const json = await res.json()
|
||||
setData(json)
|
||||
} catch (err) {
|
||||
console.warn("Using mock fallback data for College Predictor.")
|
||||
setData({
|
||||
college_prediction: "NIT Trichy / NIT Surathkal on track.",
|
||||
prediction_category: "Target"
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchReadiness()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="border-border shadow-sm h-full flex items-center justify-center min-h-[150px]">
|
||||
<RefreshCw className="animate-spin text-muted-foreground" />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const prediction = data?.college_prediction || "Keep Practicing"
|
||||
const category = data?.prediction_category || "State Level"
|
||||
|
||||
const getCategoryColor = (cat: string) => {
|
||||
switch(cat.toUpperCase()) {
|
||||
case "REACH": return "bg-purple-500/10 text-purple-600 border-purple-500/20"
|
||||
case "TARGET": return "bg-blue-500/10 text-blue-600 border-blue-500/20"
|
||||
case "SAFETY": return "bg-green-500/10 text-green-600 border-green-500/20"
|
||||
default: return "bg-secondary text-muted-foreground border-transparent"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border shadow-sm h-full flex flex-col bg-gradient-to-br from-background to-secondary/30 relative overflow-hidden">
|
||||
<div className="absolute -right-12 -top-12 opacity-5 pointer-events-none">
|
||||
<GraduationCap size={150} />
|
||||
</div>
|
||||
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="text-primary" size={24} />
|
||||
Admissions Radar
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest border ${getCategoryColor(category)}`}>
|
||||
{category}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>AI-projected admission trajectory.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 flex flex-col justify-center">
|
||||
<div className="p-5 bg-background rounded-xl border border-border shadow-sm mt-4">
|
||||
<p className="font-semibold text-lg leading-snug">
|
||||
"{prediction}"
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4 font-medium flex items-center gap-1 opacity-70">
|
||||
<RefreshCw size={10} /> Prediction updates dynamically after every test.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
80
src/components/AI/MistakeHeatmap.tsx
Normal file
80
src/components/AI/MistakeHeatmap.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Flame, Info } from "lucide-react"
|
||||
|
||||
const HEATMAP_DATA = [
|
||||
{ topic: "Rotational Dynamics", attempts: [80, 90, 40, 20, 10] }, // 10 is critical error rate
|
||||
{ topic: "Electromagnetism", attempts: [30, 25, 20, 15, 10] },
|
||||
{ topic: "Thermodynamics", attempts: [5, 10, 5, 2, 0] },
|
||||
{ topic: "Optics", attempts: [60, 50, 45, 60, 55] },
|
||||
{ topic: "Modern Physics", attempts: [10, 5, 2, 0, 0] }
|
||||
];
|
||||
|
||||
export function MistakeHeatmap() {
|
||||
const getColor = (errorRate: number) => {
|
||||
if (errorRate >= 50) return 'bg-red-500/80 border-red-500 shadow-[0_0_10px_-2px_rgba(239,68,68,0.5)]'; // Critical
|
||||
if (errorRate >= 30) return 'bg-amber-500/60 border-amber-500'; // Warning
|
||||
if (errorRate >= 10) return 'bg-yellow-500/30 border-yellow-500/50'; // Moderate
|
||||
return 'bg-green-500/20 border-green-500/30'; // Good
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-border bg-background shadow-sm h-full flex flex-col">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Flame className="text-orange-500" /> Mistake Heatmap
|
||||
</CardTitle>
|
||||
<Info size={16} className="text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Topic-wise error rate across last 5 tests.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[400px]">
|
||||
{/* Header row */}
|
||||
<div className="flex mb-2">
|
||||
<div className="w-40 shrink-0 text-xs font-bold text-muted-foreground uppercase tracking-wider">Topic</div>
|
||||
<div className="flex-1 flex justify-between text-xs font-bold text-muted-foreground uppercase tracking-wider text-center">
|
||||
<span>T-4</span>
|
||||
<span>T-3</span>
|
||||
<span>T-2</span>
|
||||
<span>T-1</span>
|
||||
<span>Latest</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data rows */}
|
||||
<div className="space-y-3">
|
||||
{HEATMAP_DATA.map((row, idx) => (
|
||||
<div key={idx} className="flex items-center group">
|
||||
<div className="w-40 shrink-0 text-sm font-medium truncate pr-2 group-hover:text-primary transition-colors">
|
||||
{row.topic}
|
||||
</div>
|
||||
<div className="flex-1 flex justify-between gap-1">
|
||||
{row.attempts.map((errRate, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 h-8 rounded-md border transition-all duration-300 group-hover:scale-105 flex items-center justify-center cursor-help relative ${getColor(errRate)}`}
|
||||
title={`Error Rate: ${errRate}%`}
|
||||
>
|
||||
<span className="opacity-0 group-hover:opacity-100 text-[10px] font-bold text-foreground absolute">{errRate}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-6 flex items-center justify-center gap-4 text-xs font-medium text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5"><div className="size-3 rounded-sm bg-green-500/20 border border-green-500/30"></div> {"< 10%"}</div>
|
||||
<div className="flex items-center gap-1.5"><div className="size-3 rounded-sm bg-yellow-500/30 border border-yellow-500/50"></div> {"10-30%"}</div>
|
||||
<div className="flex items-center gap-1.5"><div className="size-3 rounded-sm bg-amber-500/60 border border-amber-500"></div> {"30-50%"}</div>
|
||||
<div className="flex items-center gap-1.5"><div className="size-3 rounded-sm bg-red-500/80 border border-red-500"></div> {"> 50%"}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
169
src/components/AI/MistakeReviewCard.tsx
Normal file
169
src/components/AI/MistakeReviewCard.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { CheckCircle2, XCircle, BrainCircuit } from "lucide-react"
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
export interface MistakePayload {
|
||||
mistake: {
|
||||
id: string
|
||||
user_id: string
|
||||
question_id: string
|
||||
error_type: string
|
||||
topic: string
|
||||
subtopic: string
|
||||
review_count: number
|
||||
consecutive_correct: number
|
||||
is_mastered: boolean
|
||||
}
|
||||
question: {
|
||||
id: string
|
||||
subject: string
|
||||
question_text: string
|
||||
options: Record<string, string>
|
||||
correct_answer: { correct: string }
|
||||
solution_text: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MistakeReviewCardProps {
|
||||
data: MistakePayload
|
||||
onNext: () => void
|
||||
onSubmitReview: (mistakeId: string, selectedAnswer: string) => Promise<{is_correct: boolean, correct_answer: string}>
|
||||
}
|
||||
|
||||
export function MistakeReviewCard({ data, onNext, onSubmitReview }: MistakeReviewCardProps) {
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<'success' | 'error' | null>(null)
|
||||
const [correctAnswer, setCorrectAnswer] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleCheck = async () => {
|
||||
if (!selected) return;
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const res = await onSubmitReview(data.mistake.id, selected)
|
||||
setResult(res.is_correct ? 'success' : 'error')
|
||||
setCorrectAnswer(res.correct_answer)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
setSelected(null)
|
||||
setResult(null)
|
||||
setCorrectAnswer(null)
|
||||
onNext()
|
||||
}
|
||||
|
||||
const { mistake, question } = data;
|
||||
|
||||
return (
|
||||
<Card className="border-border shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-border bg-secondary/20 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="text-muted-foreground border-primary/20 bg-primary/5 text-primary">
|
||||
Attempt {mistake.review_count + 1}
|
||||
</Badge>
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">{mistake.topic} • {mistake.subtopic}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<BrainCircuit size={14} className={mistake.consecutive_correct > 0 ? "text-amber-500" : ""} />
|
||||
Consecutive Correct: <span className="font-bold text-foreground">{mistake.consecutive_correct}/3</span> before mastery
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-6 flex-1 flex flex-col">
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none text-base md:text-lg mb-8 font-medium">
|
||||
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
||||
{question.question_text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mt-auto">
|
||||
{Object.entries(question.options).map(([letter, text]) => {
|
||||
const isSelected = selected === letter
|
||||
|
||||
let btnClass = "border-border bg-background hover:border-primary/50 hover:bg-secondary/20"
|
||||
let iconClass = "border-border text-muted-foreground group-hover:border-primary/50 group-hover:text-primary"
|
||||
|
||||
if (result) {
|
||||
// Post-check styling
|
||||
if (letter === correctAnswer) {
|
||||
btnClass = "border-green-500 bg-green-500/10 shadow-[0_0_15px_-5px_#22c55e]"
|
||||
iconClass = "border-green-500 bg-green-500 text-white"
|
||||
} else if (isSelected && letter !== correctAnswer) {
|
||||
btnClass = "border-red-500 bg-red-500/10"
|
||||
iconClass = "border-red-500 bg-red-500 text-white"
|
||||
}
|
||||
} else if (isSelected) {
|
||||
btnClass = "border-primary bg-primary/5 shadow-[0_0_15px_-5px_var(--color-primary)]"
|
||||
iconClass = "border-primary bg-primary text-primary-foreground"
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={letter}
|
||||
onClick={() => !result && setSelected(letter)}
|
||||
disabled={!!result}
|
||||
className={`w-full text-left p-4 rounded-xl border transition-all duration-200 flex items-center gap-4 group ${btnClass}`}
|
||||
>
|
||||
<div className={`flex-shrink-0 size-8 rounded-lg border flex items-center justify-center font-medium transition-colors ${iconClass}`}>
|
||||
{letter}
|
||||
</div>
|
||||
<span className="text-base font-medium flex-1">
|
||||
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>{text}</ReactMarkdown>
|
||||
</span>
|
||||
{result && letter === correctAnswer && <CheckCircle2 className="text-green-500 ml-2" size={20} />}
|
||||
{result && isSelected && letter !== correctAnswer && <XCircle className="text-red-500 ml-2" size={20} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-end">
|
||||
{!result ? (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleCheck}
|
||||
disabled={!selected || isSubmitting}
|
||||
className="rounded-xl px-8 font-bold"
|
||||
>
|
||||
Check Answer
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleNext}
|
||||
className="rounded-xl px-8 font-bold bg-secondary text-foreground hover:bg-secondary/80 border border-border"
|
||||
>
|
||||
Continue to Next
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Solution Block */}
|
||||
{result && (
|
||||
<div className="mt-6 p-4 rounded-xl border border-primary/20 bg-primary/5 animate-in slide-in-from-top-2 fade-in">
|
||||
<h4 className="text-sm font-bold text-primary mb-2 flex items-center gap-2"><BrainCircuit size={16}/> AI Coach Explanation</h4>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
|
||||
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
||||
{question.solution_text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
64
src/components/AI/ProgressShare.tsx
Normal file
64
src/components/AI/ProgressShare.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Download, Image as ImageIcon, Loader2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
export function ProgressShare() {
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
// Example dynamic params
|
||||
const streak = "12"
|
||||
const accuracy = "85"
|
||||
const name = "Student"
|
||||
const imageUrl = `/api/og/progress?streak=${streak}&accuracy=${accuracy}&name=${name}`
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
setDownloading(true)
|
||||
const res = await fetch(imageUrl)
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.style.display = "none"
|
||||
a.href = url
|
||||
a.download = "PragatiVeer_Progress.png"
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
console.error("Failed to download image", err)
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border shadow-sm h-full flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<ImageIcon className="text-primary" size={24} />
|
||||
Share Progress
|
||||
</CardTitle>
|
||||
<CardDescription>Auto-generated dynamic scorecard.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 flex flex-col items-center justify-center gap-6">
|
||||
<div className="w-full max-w-sm rounded-xl overflow-hidden border border-border shadow-sm bg-secondary/20 aspect-[1200/630] relative">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Progress Scorecard"
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleDownload} variant="outline" className="w-full" disabled={downloading}>
|
||||
{downloading ? <Loader2 size={18} className="mr-2 animate-spin" /> : <Download size={18} className="mr-2" />}
|
||||
Download as PNG
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
120
src/components/AI/ReadinessGauge.tsx
Normal file
120
src/components/AI/ReadinessGauge.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Activity, RefreshCw } from "lucide-react"
|
||||
|
||||
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
||||
|
||||
export function ReadinessGauge() {
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchReadiness() {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/api/ai-coach/readiness/${USER_ID}`)
|
||||
if (!res.ok) throw new Error("Failed to fetch")
|
||||
const json = await res.json()
|
||||
setData(json)
|
||||
} catch (err) {
|
||||
console.warn("Using mock fallback data for Readiness Gauge.")
|
||||
setData({
|
||||
score: 72,
|
||||
breakdown: {
|
||||
accuracy: 30,
|
||||
speed: 15,
|
||||
consistency: 8,
|
||||
recovery: 5,
|
||||
coverage: 14
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchReadiness()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="border-border shadow-sm h-full flex items-center justify-center min-h-[300px]">
|
||||
<RefreshCw className="animate-spin text-muted-foreground" />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const score = data?.score || 0
|
||||
const breakdown = data?.breakdown || {}
|
||||
const circumference = 2 * Math.PI * 45 // r=45
|
||||
const strokeDashoffset = circumference - (score / 100) * circumference
|
||||
|
||||
const getColor = (s: number) => {
|
||||
if (s >= 75) return "text-green-500"
|
||||
if (s >= 50) return "text-yellow-500"
|
||||
return "text-red-500"
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border shadow-sm h-full flex flex-col">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Activity className="text-primary" size={24} />
|
||||
Exam Readiness
|
||||
</CardTitle>
|
||||
<CardDescription>Real-time AI composite score.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col items-center flex-1 justify-center p-6 space-y-6">
|
||||
|
||||
{/* SVG Circular Gauge */}
|
||||
<div className="relative w-32 h-32 flex items-center justify-center">
|
||||
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
|
||||
{/* Background Ring */}
|
||||
<circle cx="50" cy="50" r="45" stroke="currentColor" strokeWidth="8" fill="transparent" className="text-secondary" />
|
||||
{/* Progress Ring */}
|
||||
<circle
|
||||
cx="50" cy="50" r="45"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="transparent"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
className={`transition-all duration-1000 ease-out ${getColor(score)}`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-black">{score}</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider font-bold">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown Bars */}
|
||||
<div className="w-full space-y-3">
|
||||
{[
|
||||
{ label: "Accuracy", value: breakdown.accuracy, max: 40 },
|
||||
{ label: "Speed", value: breakdown.speed, max: 20 },
|
||||
{ label: "Coverage", value: breakdown.coverage, max: 20 },
|
||||
{ label: "Consistency", value: breakdown.consistency, max: 10 },
|
||||
{ label: "Recovery", value: breakdown.recovery, max: 10 },
|
||||
].map((item, idx) => (
|
||||
<div key={idx} className="space-y-1">
|
||||
<div className="flex justify-between text-xs font-semibold">
|
||||
<span className="text-muted-foreground">{item.label}</span>
|
||||
<span>{item.value} <span className="opacity-50">/ {item.max}</span></span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-1000 ease-out"
|
||||
style={{ width: `${(item.value / item.max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
72
src/components/AI/ReferralCard.tsx
Normal file
72
src/components/AI/ReferralCard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Link as LinkIcon, Share2, Check, Gift } from "lucide-react"
|
||||
|
||||
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
||||
|
||||
export function ReferralCard() {
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchReferral() {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/api/growth/referral/${USER_ID}`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setData(json)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Using mock fallback data for Referral Card.")
|
||||
}
|
||||
}
|
||||
fetchReferral()
|
||||
}, [])
|
||||
|
||||
const link = data?.referral_link || "https://pragativeer.com/join/demo"
|
||||
const message = data?.share_message || "Join me on PragatiVeer! Use my link to get 7 days of the AI Coach for free."
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(link)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const shareToWhatsApp = () => {
|
||||
const waUrl = `https://wa.me/?text=${encodeURIComponent(message)}`
|
||||
window.open(waUrl, "_blank")
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border shadow-sm h-full flex flex-col relative overflow-hidden bg-gradient-to-br from-background to-primary/5">
|
||||
<div className="absolute -right-6 -bottom-6 opacity-10 pointer-events-none">
|
||||
<Gift size={120} />
|
||||
</div>
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Gift className="text-primary" size={24} />
|
||||
Invite & Earn
|
||||
</CardTitle>
|
||||
<CardDescription>Give 7 days of AI Coach, get 7 days free.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 flex flex-col justify-end gap-4 z-10">
|
||||
<div className="bg-secondary/50 border border-border p-3 rounded-lg flex items-center justify-between">
|
||||
<code className="text-sm font-semibold truncate mr-4">{link}</code>
|
||||
<Button variant="secondary" size="sm" onClick={copyToClipboard} className="shrink-0 rounded-md">
|
||||
{copied ? <Check size={16} className="text-green-500" /> : <LinkIcon size={16} />}
|
||||
<span className="ml-2 hidden sm:inline">{copied ? "Copied" : "Copy"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button onClick={shareToWhatsApp} className="w-full bg-[#25D366] hover:bg-[#1ebd5a] text-white">
|
||||
<Share2 size={18} className="mr-2" /> Share via WhatsApp
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
131
src/components/AI/StudyPlanCard.tsx
Normal file
131
src/components/AI/StudyPlanCard.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { CalendarClock, CheckCircle2, Circle, Clock, Loader2, PlayCircle, RefreshCw, XCircle } from "lucide-react"
|
||||
|
||||
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
||||
|
||||
export function StudyPlanCard() {
|
||||
const [tasks, setTasks] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchPlan() {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/api/ai-coach/study-plan/today?user_id=${USER_ID}`)
|
||||
if (!res.ok) throw new Error("No plan")
|
||||
const data = await res.json()
|
||||
setTasks(data.tasks || [])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
// Mock fallback if celery hasn't seeded yet
|
||||
setTasks([
|
||||
{ type: "Revision", description: "Revise Thermodynamics", duration_minutes: 15, status: "Pending" },
|
||||
{ type: "Mistake_Review", description: "Daily Mistake Review Session", duration_minutes: 10, status: "Pending" },
|
||||
{ type: "DPS", description: "Attempt Thermodynamics Practice Sheet", duration_minutes: 20, status: "Pending" }
|
||||
])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchPlan()
|
||||
}, [])
|
||||
|
||||
const toggleTask = (idx: number, newStatus: string) => {
|
||||
setTasks(prev => {
|
||||
const copy = [...prev]
|
||||
copy[idx] = { ...copy[idx], status: newStatus }
|
||||
return copy
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border shadow-sm h-full flex flex-col">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<CalendarClock className="text-primary" size={24} />
|
||||
Today's Plan
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="bg-primary/5 text-primary border-primary/20">
|
||||
{tasks.reduce((acc, curr) => acc + curr.duration_minutes, 0)} min total
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>AI-generated curriculum targeting your weak spots.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col">
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<RefreshCw className="animate-spin text-muted-foreground mr-3" /> Loading schedule...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tasks.map((task, idx) => {
|
||||
const isDone = task.status === "Done"
|
||||
const isSkipped = task.status === "Skipped"
|
||||
const isPending = task.status === "Pending"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex items-center p-4 rounded-xl border transition-all duration-300 ${
|
||||
isDone ? "bg-green-500/5 border-green-500/20" :
|
||||
isSkipped ? "bg-secondary/50 border-transparent opacity-60" :
|
||||
"bg-background border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col flex-1 mr-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="secondary" className="text-[10px] uppercase tracking-wider">{task.type}</Badge>
|
||||
<span className="text-xs font-semibold text-muted-foreground flex items-center gap-1">
|
||||
<Clock size={12}/> {task.duration_minutes} min
|
||||
</span>
|
||||
</div>
|
||||
<p className={`font-semibold text-sm transition-all duration-300 ${
|
||||
isDone || isSkipped ? "line-through text-muted-foreground" : "text-foreground"
|
||||
}`}>
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isPending && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => toggleTask(idx, "Skipped")}
|
||||
className="p-2 rounded-full hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Skip Task"
|
||||
>
|
||||
<XCircle size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleTask(idx, "Done")}
|
||||
className="p-2 rounded-full hover:bg-green-500/10 text-muted-foreground hover:text-green-500 transition-colors"
|
||||
title="Complete Task"
|
||||
>
|
||||
<Circle size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(isDone || isSkipped) && (
|
||||
<button
|
||||
onClick={() => toggleTask(idx, "Pending")}
|
||||
className={`flex items-center gap-1 text-xs font-bold px-3 py-1.5 rounded-full ${isDone ? "bg-green-500 text-white" : "bg-secondary text-muted-foreground"}`}
|
||||
>
|
||||
{isDone ? <CheckCircle2 size={14}/> : <PlayCircle size={14}/>}
|
||||
{isDone ? "Completed" : "Resume"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
143
src/components/AI/TutorChat.tsx
Normal file
143
src/components/AI/TutorChat.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Send, Bot, User, Loader2 } from "lucide-react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkMath from "remark-math"
|
||||
import rehypeKatex from "rehype-katex"
|
||||
import "katex/dist/katex.min.css"
|
||||
|
||||
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
}
|
||||
|
||||
export function TutorChat() {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ role: "assistant", content: "Hello! I am your PragatiVeer AI Coach. What topic would you like to review today?" }
|
||||
])
|
||||
const [input, setInput] = useState("")
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
|
||||
const handleSend = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!input.trim() || streaming) return
|
||||
|
||||
const userMsg = input.trim()
|
||||
setInput("")
|
||||
setMessages(prev => [...prev, { role: "user", content: userMsg }])
|
||||
|
||||
// Add empty assistant message placeholder
|
||||
setMessages(prev => [...prev, { role: "assistant", content: "" }])
|
||||
setStreaming(true)
|
||||
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/ai-coach/chat/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: USER_ID, message: userMsg })
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Backend streaming failed. Using mock fallback.")
|
||||
}
|
||||
|
||||
if (!res.body) throw new Error("No readable stream")
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev]
|
||||
newMessages[newMessages.length - 1].content += chunk
|
||||
return newMessages
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Falling back to mock stream due to error:", err)
|
||||
// Mock Fallback Streaming logic
|
||||
const mockResponse = "It looks like my backend connection is restricted right now. However, I can still render math for you! Here is the quadratic formula:\n\n$$ x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a} $$\n\nAnd inline math: $E = mc^2$."
|
||||
const words = mockResponse.split(" ")
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50)) // Simulate network delay
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev]
|
||||
newMessages[newMessages.length - 1].content += (i === 0 ? "" : " ") + words[i]
|
||||
return newMessages
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border shadow-sm flex flex-col h-[600px]">
|
||||
<CardHeader className="border-b bg-secondary/10 pb-4">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Bot className="text-primary" size={24} />
|
||||
AI Tutor Chat
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.map((msg, idx) => (
|
||||
<div key={idx} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div className={`max-w-[80%] rounded-2xl p-4 flex gap-3 ${
|
||||
msg.role === "user" ? "bg-primary text-primary-foreground rounded-tr-sm" : "bg-secondary rounded-tl-sm"
|
||||
}`}>
|
||||
{msg.role === "assistant" && <Bot size={20} className="mt-1 shrink-0" />}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none overflow-hidden">
|
||||
{msg.role === "assistant" ? (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
>
|
||||
{msg.content || "..."}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
msg.content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</CardContent>
|
||||
|
||||
<div className="p-4 border-t bg-background">
|
||||
<form onSubmit={handleSend} className="flex gap-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Ask me to explain a concept..."
|
||||
className="flex-1 bg-secondary/50 border-transparent focus-visible:ring-primary"
|
||||
disabled={streaming}
|
||||
/>
|
||||
<Button type="submit" size="icon" disabled={!input.trim() || streaming}>
|
||||
{streaming ? <Loader2 className="animate-spin" size={18} /> : <Send size={18} />}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
111
src/components/CBT/Calculator.tsx
Normal file
111
src/components/CBT/Calculator.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Delete } from "lucide-react";
|
||||
|
||||
interface CalculatorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Calculator({ isOpen, onClose }: CalculatorProps) {
|
||||
const [display, setDisplay] = useState("");
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleInput = (val: string) => {
|
||||
setDisplay((prev) => prev + val);
|
||||
};
|
||||
|
||||
const handleCalculate = () => {
|
||||
try {
|
||||
// Map functions to JS Math
|
||||
let mappedExp = display
|
||||
.replace(/sin\(/g, 'Math.sin(')
|
||||
.replace(/cos\(/g, 'Math.cos(')
|
||||
.replace(/tan\(/g, 'Math.tan(')
|
||||
.replace(/log\(/g, 'Math.log10(')
|
||||
.replace(/ln\(/g, 'Math.log(')
|
||||
.replace(/sqrt\(/g, 'Math.sqrt(')
|
||||
.replace(/\^/g, '**');
|
||||
|
||||
const result = new Function('return ' + mappedExp)();
|
||||
|
||||
// Handle floating point precision issues simply
|
||||
if (typeof result === 'number' && !Number.isInteger(result)) {
|
||||
setDisplay(String(parseFloat(result.toFixed(6))));
|
||||
} else {
|
||||
setDisplay(String(result));
|
||||
}
|
||||
} catch {
|
||||
setDisplay("Error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => setDisplay("");
|
||||
const handleBackspace = () => setDisplay((prev) => prev.slice(0, -1));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="bg-background border border-border shadow-2xl rounded-2xl w-[400px] overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/50">
|
||||
<h3 className="font-bold">Scientific Calculator</h3>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 bg-background">
|
||||
<div className="bg-secondary p-4 rounded-xl text-right text-3xl font-mono mb-4 overflow-x-auto min-h-16 flex items-center justify-end break-all shadow-inner">
|
||||
{display || "0"}
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{/* Scientific Row 1 */}
|
||||
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('sin(')}>sin</Button>
|
||||
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('cos(')}>cos</Button>
|
||||
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('tan(')}>tan</Button>
|
||||
<Button variant="outline" className="font-bold text-destructive hover:bg-destructive/10" onClick={handleClear}>AC</Button>
|
||||
<Button variant="outline" onClick={handleBackspace}><Delete size={18} /></Button>
|
||||
|
||||
{/* Scientific Row 2 */}
|
||||
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('log(')}>log</Button>
|
||||
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('ln(')}>ln</Button>
|
||||
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('sqrt(')}>√</Button>
|
||||
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('(')}>(</Button>
|
||||
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput(')')}>)</Button>
|
||||
|
||||
{/* Number Row 1 */}
|
||||
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('^')}>x^y</Button>
|
||||
<Button variant="outline" className="font-bold" onClick={() => handleInput('7')}>7</Button>
|
||||
<Button variant="outline" className="font-bold" onClick={() => handleInput('8')}>8</Button>
|
||||
<Button variant="outline" className="font-bold" onClick={() => handleInput('9')}>9</Button>
|
||||
<Button variant="outline" className="text-primary font-bold bg-primary/10" onClick={() => handleInput('/')}>÷</Button>
|
||||
|
||||
{/* Number Row 2 */}
|
||||
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('3.14159')}>π</Button>
|
||||
<Button variant="outline" className="font-bold" onClick={() => handleInput('4')}>4</Button>
|
||||
<Button variant="outline" className="font-bold" onClick={() => handleInput('5')}>5</Button>
|
||||
<Button variant="outline" className="font-bold" onClick={() => handleInput('6')}>6</Button>
|
||||
<Button variant="outline" className="text-primary font-bold bg-primary/10" onClick={() => handleInput('*')}>×</Button>
|
||||
|
||||
{/* Number Row 3 */}
|
||||
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('2.71828')}>e</Button>
|
||||
<Button variant="outline" className="font-bold" onClick={() => handleInput('1')}>1</Button>
|
||||
<Button variant="outline" className="font-bold" onClick={() => handleInput('2')}>2</Button>
|
||||
<Button variant="outline" className="font-bold" onClick={() => handleInput('3')}>3</Button>
|
||||
<Button variant="outline" className="text-primary font-bold bg-primary/10" onClick={() => handleInput('-')}>-</Button>
|
||||
|
||||
{/* Number Row 4 */}
|
||||
<div className="col-span-1"></div>
|
||||
<Button variant="outline" className="col-span-2 font-bold" onClick={() => handleInput('0')}>0</Button>
|
||||
<Button variant="outline" className="font-bold" onClick={() => handleInput('.')}>.</Button>
|
||||
<Button variant="outline" className="text-primary font-bold bg-primary/10" onClick={() => handleInput('+')}>+</Button>
|
||||
|
||||
{/* Equals */}
|
||||
<Button className="col-span-5 font-bold shadow-[0_0_15px_-5px_var(--color-primary)] mt-1" onClick={handleCalculate}>=</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/components/CBT/ExamResult.tsx
Normal file
127
src/components/CBT/ExamResult.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { CheckCircle2, XCircle, AlertCircle, Clock, Award, ArrowRight, RotateCcw } from "lucide-react";
|
||||
import Link from 'next/link';
|
||||
|
||||
interface ExamAttemptPayload {
|
||||
score: number;
|
||||
total_questions: number;
|
||||
accuracy: number;
|
||||
time_taken: number;
|
||||
answers: Array<{
|
||||
question_id: string;
|
||||
selected_answer: string | null;
|
||||
is_correct: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ExamResultProps {
|
||||
payload: ExamAttemptPayload;
|
||||
onRetake: () => void;
|
||||
}
|
||||
|
||||
export function ExamResult({ payload, onRetake }: ExamResultProps) {
|
||||
const answered = payload.answers.filter(a => a.selected_answer !== null).length;
|
||||
const unanswered = payload.total_questions - answered;
|
||||
|
||||
// Calculate correct based on score formula (+4 for correct, -1 for wrong).
|
||||
// Wait, payload score is just correct count according to seed script right now, let's assume it.
|
||||
const correctCount = payload.answers.filter(a => a.is_correct).length;
|
||||
const incorrectCount = answered - correctCount;
|
||||
|
||||
// Real score calculation: +4 correct, -1 incorrect
|
||||
const finalScore = (correctCount * 4) - (incorrectCount * 1);
|
||||
const maxPossibleScore = payload.total_questions * 4;
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||
return `${m}m ${s}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col items-center py-12 px-4 sm:px-6 animate-in fade-in zoom-in duration-500">
|
||||
<div className="max-w-4xl w-full space-y-8">
|
||||
|
||||
<div className="text-center space-y-4">
|
||||
<div className="inline-flex items-center justify-center size-20 rounded-full bg-green-500/10 mb-4">
|
||||
<Award className="size-10 text-green-500" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight">Exam Successfully Submitted</h1>
|
||||
<p className="text-xl text-muted-foreground font-medium">Here is a quick snapshot of your performance.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mt-10">
|
||||
<Card className="bg-secondary/20 border-border">
|
||||
<CardContent className="p-6 flex flex-col items-center justify-center text-center space-y-2">
|
||||
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Final Score</span>
|
||||
<div className="text-4xl font-black text-primary">
|
||||
{finalScore} <span className="text-lg text-muted-foreground font-medium">/ {maxPossibleScore}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-secondary/20 border-border">
|
||||
<CardContent className="p-6 flex flex-col items-center justify-center text-center space-y-2">
|
||||
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Accuracy</span>
|
||||
<div className="text-4xl font-black text-blue-500">
|
||||
{Math.round(payload.accuracy)}%
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-secondary/20 border-border">
|
||||
<CardContent className="p-6 flex flex-col items-center justify-center text-center space-y-2">
|
||||
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Time Taken</span>
|
||||
<div className="flex items-center gap-2 text-2xl font-black text-amber-500 mt-1">
|
||||
<Clock className="size-6" /> {formatTime(payload.time_taken)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-secondary/20 border-border">
|
||||
<CardContent className="p-6 flex flex-col items-center justify-center text-center space-y-2">
|
||||
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Attempted</span>
|
||||
<div className="text-4xl font-black text-purple-500">
|
||||
{answered} <span className="text-lg text-muted-foreground font-medium">/ {payload.total_questions}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 pt-4">
|
||||
<div className="bg-green-500/10 border border-green-500/20 rounded-2xl p-6 text-center">
|
||||
<CheckCircle2 className="size-8 text-green-500 mx-auto mb-2" />
|
||||
<div className="text-3xl font-black text-green-500">{correctCount}</div>
|
||||
<div className="text-sm font-bold text-green-600/70 uppercase tracking-wide mt-1">Correct</div>
|
||||
</div>
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-2xl p-6 text-center">
|
||||
<XCircle className="size-8 text-red-500 mx-auto mb-2" />
|
||||
<div className="text-3xl font-black text-red-500">{incorrectCount}</div>
|
||||
<div className="text-sm font-bold text-red-600/70 uppercase tracking-wide mt-1">Incorrect</div>
|
||||
</div>
|
||||
<div className="bg-secondary border border-border rounded-2xl p-6 text-center">
|
||||
<AlertCircle className="size-8 text-muted-foreground mx-auto mb-2" />
|
||||
<div className="text-3xl font-black text-foreground">{unanswered}</div>
|
||||
<div className="text-sm font-bold text-muted-foreground uppercase tracking-wide mt-1">Unanswered</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-8">
|
||||
<Button variant="outline" size="lg" className="rounded-xl h-14 px-8 font-bold text-lg gap-2" onClick={onRetake}>
|
||||
<RotateCcw className="size-5" /> Retake Mock Test
|
||||
</Button>
|
||||
<Link href="/dashboard/cbt">
|
||||
<Button size="lg" className="rounded-xl h-14 px-8 font-bold text-lg shadow-[0_0_20px_-5px_var(--color-primary)] gap-2 w-full sm:w-auto">
|
||||
Dashboard <ArrowRight className="size-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/CBT/PeriodicTable.tsx
Normal file
43
src/components/CBT/PeriodicTable.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { X, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Image from "next/image";
|
||||
|
||||
interface PeriodicTableProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PeriodicTable({ isOpen, onClose }: PeriodicTableProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md animate-in fade-in duration-300">
|
||||
<div className="bg-background border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-300 flex flex-col w-[90vw] max-w-5xl h-[85vh]">
|
||||
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/80">
|
||||
<h3 className="font-bold text-lg">Periodic Table of Elements</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm"><ExternalLink size={14} className="mr-2"/> Open Fullscreen</Button>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground ml-2 p-2">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-zinc-950 p-6 flex items-center justify-center relative overflow-auto">
|
||||
{/* We will use a high quality remote SVG/image for the periodic table reference */}
|
||||
<div className="relative w-full h-full min-w-[800px] min-h-[500px]">
|
||||
{/* Using a reliable wikimedia SVG for Periodic Table as a reference */}
|
||||
<Image
|
||||
src="https://upload.wikimedia.org/wikipedia/commons/4/4d/Periodic_table_large.svg"
|
||||
alt="Periodic Table"
|
||||
fill
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
src/components/CBT/Scratchpad.tsx
Normal file
177
src/components/CBT/Scratchpad.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Eraser, Pen } from "lucide-react";
|
||||
|
||||
interface ScratchpadProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Scratchpad({ isOpen, onClose }: ScratchpadProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [mode, setMode] = useState<"pen" | "eraser">("pen");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
// Initialize and load saved session memory
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const savedNotes = localStorage.getItem("cbt_scratchpad_notes");
|
||||
if (savedNotes) setNotes(savedNotes);
|
||||
|
||||
if (canvasRef.current) {
|
||||
const canvas = canvasRef.current;
|
||||
canvas.width = 500;
|
||||
canvas.height = 500;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "white";
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
const savedDrawing = localStorage.getItem("cbt_scratchpad_drawing");
|
||||
if (savedDrawing) {
|
||||
const img = new window.Image();
|
||||
img.onload = () => {
|
||||
ctx.drawImage(img, 0, 0);
|
||||
};
|
||||
img.src = savedDrawing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const startDrawing = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
setIsDrawing(true);
|
||||
draw(e);
|
||||
};
|
||||
|
||||
const stopDrawing = () => {
|
||||
setIsDrawing(false);
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.beginPath();
|
||||
localStorage.setItem("cbt_scratchpad_drawing", canvas.toDataURL());
|
||||
}
|
||||
};
|
||||
|
||||
const draw = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (!isDrawing || !canvasRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let clientX, clientY;
|
||||
if ('touches' in e) {
|
||||
clientX = e.touches[0].clientX;
|
||||
clientY = e.touches[0].clientY;
|
||||
} else {
|
||||
clientX = (e as React.MouseEvent).clientX;
|
||||
clientY = (e as React.MouseEvent).clientY;
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
};
|
||||
|
||||
const clearCanvas = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (ctx && canvas) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
localStorage.removeItem("cbt_scratchpad_drawing");
|
||||
}
|
||||
};
|
||||
|
||||
const clearNotes = () => {
|
||||
setNotes("");
|
||||
localStorage.removeItem("cbt_scratchpad_notes");
|
||||
};
|
||||
|
||||
const handleModeSwitch = (newMode: "pen" | "eraser") => {
|
||||
setMode(newMode);
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (ctx) {
|
||||
if (newMode === "eraser") {
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.lineWidth = 20;
|
||||
} else {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.lineWidth = 2;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotesChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
setNotes(val);
|
||||
localStorage.setItem("cbt_scratchpad_notes", val);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200 p-8">
|
||||
<div className="bg-background border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-200 flex flex-col w-full max-w-6xl h-full max-h-[800px]">
|
||||
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/50">
|
||||
<h3 className="font-bold flex items-center gap-2"><Pen size={18}/> Split Workspace: Draw & Type</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant={mode === "pen" ? "default" : "outline"} size="sm" onClick={() => handleModeSwitch("pen")}><Pen size={14} className="mr-2"/> Pen</Button>
|
||||
<Button variant={mode === "eraser" ? "default" : "outline"} size="sm" onClick={() => handleModeSwitch("eraser")}><Eraser size={14} className="mr-2"/> Eraser</Button>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground ml-4 bg-secondary p-2 rounded-lg">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex overflow-hidden bg-zinc-950">
|
||||
{/* Drawing Column */}
|
||||
<div className="flex-1 flex flex-col border-r border-zinc-800 relative">
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<Button variant="outline" size="sm" onClick={clearCanvas} className="bg-zinc-900 border-zinc-700 text-zinc-300">Clear Drawing</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4 flex items-center justify-center">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseDown={startDrawing}
|
||||
onMouseUp={stopDrawing}
|
||||
onMouseOut={stopDrawing}
|
||||
onMouseMove={draw}
|
||||
onTouchStart={startDrawing}
|
||||
onTouchEnd={stopDrawing}
|
||||
onTouchMove={draw}
|
||||
className="bg-zinc-900 border border-zinc-800 rounded-lg cursor-crosshair touch-none shadow-inner"
|
||||
style={{ width: '500px', height: '500px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Typing Column */}
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<Button variant="outline" size="sm" onClick={clearNotes} className="bg-zinc-900 border-zinc-700 text-zinc-300">Clear Notes</Button>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={handleNotesChange}
|
||||
placeholder="Type your rough notes, formulas, and calculations here..."
|
||||
className="w-full h-full bg-zinc-900 border border-zinc-800 rounded-lg shadow-inner text-zinc-100 p-6 resize-none focus:outline-none focus:ring-1 focus:ring-primary text-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/LanguageToggle.tsx
Normal file
37
src/components/LanguageToggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Languages } from "lucide-react"
|
||||
|
||||
export function LanguageToggle() {
|
||||
const [lang, setLang] = useState("EN")
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button className="flex items-center gap-2 p-2 rounded-xl hover:bg-secondary/80 transition-colors border border-transparent hover:border-border font-medium text-sm text-muted-foreground hover:text-foreground">
|
||||
<Languages size={18} />
|
||||
<span className="hidden sm:inline">{lang === "EN" ? "English" : lang === "HI" ? "Hindi" : "Bilingual"}</span>
|
||||
</button>
|
||||
<div className="absolute top-full right-0 mt-2 w-36 bg-background border border-border rounded-xl shadow-lg opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all z-50 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setLang("EN")}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-secondary transition-colors ${lang === "EN" ? "font-bold text-primary" : "text-foreground"}`}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLang("HI")}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-secondary transition-colors ${lang === "HI" ? "font-bold text-primary" : "text-foreground"}`}
|
||||
>
|
||||
Hindi
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLang("BI")}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-secondary transition-colors ${lang === "BI" ? "font-bold text-primary" : "text-foreground"}`}
|
||||
>
|
||||
Bilingual
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
src/components/StreakCounter.tsx
Normal file
16
src/components/StreakCounter.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { Flame } from "lucide-react"
|
||||
|
||||
interface StreakCounterProps {
|
||||
streak: number
|
||||
}
|
||||
|
||||
export function StreakCounter({ streak }: StreakCounterProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-orange-500/10 border border-orange-500/20 text-orange-500 font-bold shadow-[0_0_10px_-2px_rgba(249,115,22,0.5)] cursor-default transition-transform hover:scale-105">
|
||||
<Flame size={18} className="animate-pulse" />
|
||||
<span>{streak}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
src/components/ThemeToggle.tsx
Normal file
41
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
setIsDark(true);
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
setIsDark(false);
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (isDark) {
|
||||
document.documentElement.classList.remove('dark')
|
||||
localStorage.setItem('theme', 'light');
|
||||
setIsDark(false)
|
||||
} else {
|
||||
document.documentElement.classList.add('dark')
|
||||
localStorage.setItem('theme', 'dark');
|
||||
setIsDark(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-xl hover:bg-secondary/80 transition-colors border border-transparent hover:border-border text-muted-foreground hover:text-foreground"
|
||||
aria-label="Toggle Dark Mode"
|
||||
>
|
||||
{isDark ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
21
src/components/TierBadge.tsx
Normal file
21
src/components/TierBadge.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
interface TierBadgeProps {
|
||||
tier: "Free" | "Pro" | "AI Coach"
|
||||
}
|
||||
|
||||
export function TierBadge({ tier }: TierBadgeProps) {
|
||||
if (tier === "Free") {
|
||||
return <Badge variant="outline" className="text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-secondary/30">Free</Badge>
|
||||
}
|
||||
|
||||
if (tier === "Pro") {
|
||||
return <Badge className="text-xs font-semibold uppercase tracking-wider bg-primary/10 text-primary border-primary/20 hover:bg-primary/20 transition-colors">Pro</Badge>
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge className="text-xs font-semibold uppercase tracking-wider bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20 hover:bg-[#00F0FF]/20 transition-colors shadow-[0_0_10px_-2px_#00F0FF]">
|
||||
AI Coach
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
23
src/components/ui/input.tsx
Normal file
23
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-xl border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
18
src/components/ui/label.tsx
Normal file
18
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Label.displayName = "Label"
|
||||
|
||||
export { Label }
|
||||
48
src/components/ui/switch.tsx
Normal file
48
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface SwitchProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, checked, onCheckedChange, disabled, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "",
|
||||
checked ? "bg-primary" : "bg-border",
|
||||
className
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled && onCheckedChange) {
|
||||
onCheckedChange(!checked);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="peer sr-only"
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
if (onCheckedChange) onCheckedChange(e.target.checked);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
|
||||
checked ? "translate-x-5" : "translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Switch.displayName = "Switch"
|
||||
|
||||
export { Switch }
|
||||
Reference in New Issue
Block a user