Compare commits

13 Commits

Author SHA1 Message Date
3c8f945539 fix(ui): change ui theme color
feat(calc): add geogebra based graph calculator for tests
2026-02-20 00:03:23 +06:00
626616c8b5 fix(ui): fix minor ui bugs 2026-02-17 16:41:24 +06:00
b56642fda8 fix(ui): fix minor ui bugs 2026-02-15 18:37:28 +06:00
e5305a1ca2 feat(ui): add sidebar navigation for desktop 2026-02-15 17:24:11 +06:00
96eb2c13b0 feat(home): add spotlight search functionality 2026-02-14 03:24:22 +06:00
7f82e640e0 feat(results): add resutls page
fix(leaderboard): fix leaderboard fetch logic

fix(test): fix navigation bug upon test quit
2026-02-10 19:32:46 +06:00
8cfcb11f0a feat(error): add error handling on test screen 2026-02-07 20:21:47 +06:00
c9db96f97f feat(leaderboard): add leaderboard functionaltiy 2026-02-07 18:58:50 +06:00
02419678b7 feat(test): add functionality for drill, hard test module testing 2026-02-07 15:28:43 +06:00
903653a212 feat(targeted): add targeted practice functionality
feat(analytics); add analytics page
2026-02-05 15:07:24 +06:00
2ac88835f9 feat(lesson): add lesson modal 2026-02-01 18:20:03 +06:00
62238cbf8f feat(pages): add targeted practice, drills, hard test modules pages 2026-02-01 17:35:35 +06:00
60e858c931 feat(test); add strikethrough functionality for mcq options 2026-02-01 17:10:38 +06:00
55 changed files with 6995 additions and 486 deletions

View File

@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://www.geogebra.org/apps/deployggb.js"></script>
<title>Edbridge Scholars</title> <title>Edbridge Scholars</title>
</head> </head>
<body> <body>

View File

@ -17,11 +17,14 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "^12.30.0",
"katex": "^0.16.28", "katex": "^0.16.28",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-katex": "^3.1.0", "react-katex": "^3.1.0",

976
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,10 @@ import { Test } from "./pages/student/practice/Test";
import { Profile } from "./pages/student/Profile"; import { Profile } from "./pages/student/Profile";
import { Rewards } from "./pages/student/Rewards"; import { Rewards } from "./pages/student/Rewards";
import { StudentLayout } from "./pages/student/StudentLayout"; import { StudentLayout } from "./pages/student/StudentLayout";
import { TargetedPractice } from "./pages/student/targeted-practice/page";
import { Drills } from "./pages/student/drills/page";
import { HardTestModules } from "./pages/student/hard-test-modules/page";
import { Analytics } from "./pages/student/Analytics";
function App() { function App() {
const router = createBrowserRouter([ const router = createBrowserRouter([
@ -50,13 +54,28 @@ function App() {
path: "profile", path: "profile",
element: <Profile />, element: <Profile />,
}, },
{
path: "analytics",
element: <Analytics />,
},
{ {
path: "practice/:sheetId", path: "practice/:sheetId",
element: <Pretest />, element: <Pretest />,
}, },
{
path: "practice/targeted-practice",
element: <TargetedPractice />,
},
{
path: "practice/drills",
element: <Drills />,
},
{
path: "practice/hard-test-modules",
element: <HardTestModules />,
},
], ],
}, },
{ {
path: "practice/:sheetId/test", path: "practice/:sheetId/test",
element: <Test />, element: <Test />,

View File

@ -0,0 +1,216 @@
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuSub,
} from "../components/ui/sidebar";
import {
ChevronDown,
BookOpen,
Home,
Video,
User,
Target,
Zap,
Trophy,
LayoutGrid,
} from "lucide-react";
import { useState } from "react";
import logo from "../assets/ed_logo1.png";
import { NavLink } from "react-router-dom";
import { useAuthStore } from "../stores/authStore";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
export function AppSidebar() {
const [open, setOpen] = useState(true);
const user = useAuthStore((s) => s.user);
return (
<Sidebar className="border-r bg-black text-white">
{/* HEADER */}
<SidebarHeader>
<div className="flex items-center justify-between px-2 py-2 rounded-lg hover:bg-white/10 cursor-pointer">
<div className="flex items-center gap-3">
<div className="flex rounded-md w-10 h-10 border overflow-hidden">
<img
src={logo}
className="w-full h-full object-cover object-left"
alt="Logo"
/>
</div>
<div className="flex flex-col text-sm">
<span className="font-satoshi-medium text-black">
Edbridge Scholars
</span>
<span className="text-xs text-gray-400 font-satoshi">
Student
</span>
</div>
</div>
<ChevronDown size={16} />
</div>
</SidebarHeader>
{/* CONTENT */}
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-gray-400 font-satoshi">
Platform
</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<NavLink
to="/student/home"
className={({ isActive }) =>
isActive
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:bg-zinc-800"
}
>
<Home size={18} className="text-black" />
<span className="font-satoshi text-black">Home</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
className="cursor-pointer"
asChild
onClick={() => setOpen(!open)}
>
<div>
<BookOpen size={18} className="text-black" />
<span className="font-satoshi text-black">Practice</span>
<ChevronDown
size={16}
className={`ml-auto transition-transform ${
open ? "rotate-180" : ""
}`}
/>
</div>
</SidebarMenuButton>
{open && (
<SidebarMenuSub className="space-y-3 mt-2">
<NavLink
to="/student/practice"
className="text-black text-sm flex items-center gap-3"
>
<LayoutGrid size={18} className="text-black" />
<span className="font-satoshi text-black">
Practice your way
</span>
</NavLink>
<NavLink
to="/student/practice/targeted-practice"
className="text-black text-sm flex items-center gap-3"
>
<Target size={18} className="text-black" />
<span className="font-satoshi text-black">
Targeted Practice
</span>
</NavLink>
<NavLink
to="/student/practice/drills"
className="text-black text-sm flex items-center gap-3"
>
<Zap size={18} className="text-black" />
<span className="font-satoshi text-black">Drills</span>
</NavLink>
<NavLink
to="/student/practice/hard-test-modules"
className="text-black text-sm flex items-center gap-3"
>
<Trophy size={18} className="text-black" />
<span className="font-satoshi text-black">
Hard Test Modules
</span>
</NavLink>
</SidebarMenuSub>
)}
</SidebarMenuItem>
{/* DOCS */}
<SidebarMenuItem>
<NavLink
to={`/student/lessons`}
className={({ isActive }) =>
isActive
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:bg-zinc-800"
}
>
<SidebarMenuButton className="cursor-pointer">
<Video size={18} className="text-black" />
<span className="text-black font-satoshi">Lessons</span>
</SidebarMenuButton>
</NavLink>
</SidebarMenuItem>
{/* SETTINGS */}
<SidebarMenuItem>
<NavLink
to={`/student/rewards`}
className={({ isActive }) =>
isActive
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:bg-zinc-800"
}
>
<SidebarMenuButton className="cursor-pointer">
<Trophy size={18} className="text-black" />
<span className="text-black font-satoshi">Rewards</span>
</SidebarMenuButton>
</NavLink>
</SidebarMenuItem>
<SidebarMenuItem>
<NavLink
to={`/student/profile`}
className={({ isActive }) =>
isActive
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:bg-zinc-800"
}
>
<SidebarMenuButton className="cursor-pointer">
<User size={18} className="text-black" />
<span className="text-black font-satoshi">Profile</span>
</SidebarMenuButton>
</NavLink>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
{/* FOOTER */}
<SidebarFooter>
<div className="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-white/10 cursor-pointer">
<Avatar>
<AvatarImage src={user?.avatar_url} />
<AvatarFallback className="font-satoshi-bold bg-linear-to-br from-purple-400 to-purple-500 uppercase">
{user?.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col text-sm">
<span className="font-medium text-black">{user?.name}</span>
<span className="text-xs text-gray-400">{user?.email}</span>
</div>
<ChevronDown size={16} className="ml-auto" />
</div>
</SidebarFooter>
</Sidebar>
);
}

View File

@ -0,0 +1,250 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, Calculator, Maximize2, Minimize2 } from "lucide-react";
// ─── GeoGebra type shim ───────────────────────────────────────────────────────
declare global {
interface Window {
GGBApplet: new (
params: Record<string, unknown>,
defer?: boolean,
) => {
inject: (containerId: string) => void;
};
ggbApplet?: {
reset: () => void;
setXML: (xml: string) => void;
};
}
}
// ─── Hook: load GeoGebra script once ─────────────────────────────────────────
const GEOGEBRA_SCRIPT = "https://www.geogebra.org/apps/deployggb.js";
const useGeoGebraScript = () => {
const [ready, setReady] = useState(false);
useEffect(() => {
if (document.querySelector(`script[src="${GEOGEBRA_SCRIPT}"]`)) {
if (window.GGBApplet) setReady(true);
return;
}
const script = document.createElement("script");
script.src = GEOGEBRA_SCRIPT;
script.async = true;
script.onload = () => setReady(true);
document.head.appendChild(script);
}, []);
return ready;
};
// ─── GeoGebra Calculator ──────────────────────────────────────────────────────
const GeoGebraCalculator = ({ containerId }: { containerId: string }) => {
const scriptReady = useGeoGebraScript();
const injected = useRef(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
// Measure the wrapper first — GeoGebra needs explicit px dimensions
useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) {
setDims({ w: Math.floor(width), h: Math.floor(height) });
}
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
if (!scriptReady || !dims || injected.current) return;
injected.current = true;
const params = {
appName: "graphing",
width: dims.w,
height: dims.h,
showToolBar: true,
showAlgebraInput: true,
showMenuBar: false,
enableLabelDrags: true,
enableShiftDragZoom: true,
enableRightClick: true,
showZoomButtons: true,
capturingThreshold: null,
showFullscreenButton: false,
scale: 1,
disableAutoScale: false,
allowUpscale: false,
clickToLoad: false,
appletOnLoad: () => {},
useBrowserForJS: false,
showLogging: false,
errorDialogsActive: true,
showTutorialLink: false,
showSuggestionButtons: false,
language: "en",
id: "ggbApplet",
};
try {
const applet = new window.GGBApplet(params, true);
applet.inject(containerId);
} catch (e) {
console.error("GeoGebra init error:", e);
}
}, [scriptReady, dims, containerId]);
return (
<div ref={wrapperRef} className="w-full h-full">
{!dims && (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-sm font-satoshi gap-2">
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
/>
</svg>
Loading calculator...
</div>
)}
<div id={containerId} style={{ width: dims?.w, height: dims?.h }} />
</div>
);
};
// ─── Modal ────────────────────────────────────────────────────────────────────
interface GraphCalculatorModalProps {
open: boolean;
onClose: () => void;
}
const GraphCalculatorModal = ({ open, onClose }: GraphCalculatorModalProps) => {
const [fullscreen, setFullscreen] = useState(false);
const containerId = "geogebra-container";
// Trap focus & keyboard dismiss
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Prevent body scroll while open
useEffect(() => {
document.body.style.overflow = open ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [open]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-label="Graph Calculator"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div
className={`
relative z-10 flex flex-col bg-white rounded-2xl shadow-2xl overflow-hidden
transition-all duration-300
${
fullscreen
? "w-screen h-screen rounded-none"
: "w-[95vw] h-[90vh] max-w-5xl"
}
`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-white shrink-0">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-purple-50 border border-purple-100">
<Calculator size={16} className="text-purple-500" />
</div>
<span className="font-satoshi-bold text-gray-800 text-sm">
Graph Calculator
</span>
<span className="text-[10px] font-satoshi text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
Powered by GeoGebra
</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => setFullscreen((f) => !f)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition"
title={fullscreen ? "Exit fullscreen" : "Fullscreen"}
>
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 transition"
title="Close"
>
<X size={16} />
</button>
</div>
</div>
{/* GeoGebra canvas area */}
<div className="flex-1 overflow-hidden">
<GeoGebraCalculator containerId={containerId} />
</div>
</div>
</div>,
document.body,
);
};
// ─── Trigger button + modal — drop this wherever you need it ──────────────────
export const GraphCalculatorButton = () => {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-full font-satoshi-medium text-sm"
>
<Calculator size={16} />
Calculator
</button>
<GraphCalculatorModal open={open} onClose={() => setOpen(false)} />
</>
);
};
// ─── Standalone modal export if you need to control it externally ─────────────
export { GraphCalculatorModal };

View File

@ -0,0 +1,34 @@
import { Badge } from "./ui/badge";
export const ChoiceCard = ({
label,
selected,
subLabel,
section,
onClick,
}: {
label: string;
selected?: boolean;
subLabel?: string;
section?: string;
onClick: () => void;
}) => (
<button
onClick={onClick}
className={`rounded-2xl border p-4 text-left transition flex flex-col
${selected ? "border-indigo-600 bg-indigo-50" : "hover:border-gray-300"}`}
>
<div className="flex justify-between">
<span className="font-satoshi-bold text-lg">{label}</span>
{section && (
<Badge
variant={"secondary"}
className={`font-satoshi text-sm ${section === "EBRW" ? "bg-blue-400 text-blue-100" : "bg-red-400 text-red-100"}`}
>
{section}
</Badge>
)}
</div>
{subLabel && <span className="font-satoshi text-md">{subLabel}</span>}
</button>
);

View File

@ -0,0 +1,145 @@
import { useEffect, useState } from "react";
import { ConfettiBurst } from "./ConfettiBurst";
type Props = {
size?: number;
strokeWidth?: number;
previousXP: number;
gainedXP: number;
levelMinXP: number;
levelMaxXP: number;
level: number;
};
export const CircularLevelProgress = ({
size = 300,
strokeWidth = 16,
previousXP,
gainedXP,
levelMinXP,
levelMaxXP,
level,
}: Props) => {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const levelRange = levelMaxXP - levelMinXP;
const normalize = (xp: number) =>
Math.min(Math.max(xp - levelMinXP, 0), levelRange) / levelRange;
const [progress, setProgress] = useState(normalize(previousXP));
const [currentLevel, setCurrentLevel] = useState(level);
const [showLevelUp, setShowLevelUp] = useState(false);
const [showThresholdText, setShowThresholdText] = useState(false);
useEffect(() => {
let animationFrame: number;
let start: number | null = null;
const availableXP = previousXP + gainedXP;
const crossesLevel = availableXP >= levelMaxXP;
const phase1Target = crossesLevel ? 1 : normalize(previousXP + gainedXP);
const leftoverXP = crossesLevel ? availableXP - levelMaxXP : 0;
const duration = 1200;
const animatePhase1 = (timestamp: number) => {
if (!start) start = timestamp;
const t = Math.min((timestamp - start) / duration, 1);
setProgress(
normalize(previousXP) + t * (phase1Target - normalize(previousXP)),
);
if (t < 1) {
animationFrame = requestAnimationFrame(animatePhase1);
} else if (crossesLevel) {
setShowLevelUp(true);
setTimeout(startPhase2, 1200);
} else {
setShowThresholdText(true);
}
};
const startPhase2 = () => {
start = null;
setShowLevelUp(false);
setCurrentLevel((l) => l + 1);
setProgress(0);
const target = Math.min(leftoverXP / levelRange, 1);
const animatePhase2 = (timestamp: number) => {
if (!start) start = timestamp;
const t = Math.min((timestamp - start) / duration, 1);
setProgress(t * target);
if (t < 1) {
animationFrame = requestAnimationFrame(animatePhase2);
} else {
setShowThresholdText(true);
}
};
animationFrame = requestAnimationFrame(animatePhase2);
};
animationFrame = requestAnimationFrame(animatePhase1);
return () => cancelAnimationFrame(animationFrame);
}, []);
const offset = circumference * (1 - progress);
return (
<div className="relative flex flex-col items-center gap-2">
{showLevelUp && <ConfettiBurst />}
<div
className="relative flex items-center justify-center"
style={{ width: size, height: size }}
>
<svg width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="oklch(94.6% 0.033 307.174)"
strokeWidth={strokeWidth}
fill="none"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="oklch(62.7% 0.265 303.9)"
strokeWidth={strokeWidth}
fill="none"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
<span className="absolute text-[100px] font-satoshi-bold flex flex-col items-center">
{currentLevel}
{showThresholdText && (
<span className="text-xl font-satoshi-medium text-gray-500 animate-fade-in">
Total XP: {previousXP + gainedXP}
</span>
)}
{showLevelUp && (
<span className="text-xl font-satoshi-medium text-purple-600 animate-fade-in">
🎉 You leveled up!
</span>
)}
</span>
</div>
</div>
);
};

View File

@ -0,0 +1,73 @@
type CircularProgressProps = {
value: number;
min?: number;
max?: number;
size?: number;
strokeWidth?: number;
};
export function CircularProgress({
value,
min = 0,
max = 100,
size = 80,
strokeWidth = 6,
}: CircularProgressProps) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
// normalize value to 01
const normalized = max === min ? 0 : (value - min) / (max - min);
// clamp between 0 and 1
const clamped = Math.min(1, Math.max(0, normalized));
const offset = circumference * (1 - clamped);
return (
<svg width={size} height={size}>
{/* background */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#fff"
strokeOpacity={0.5}
strokeWidth={strokeWidth}
fill="transparent"
/>
{/* progress */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#fff"
strokeWidth={strokeWidth}
fill="transparent"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
style={{
transform: "rotate(-90deg)",
transformOrigin: "50% 50%",
transition: "stroke-dashoffset 0.4s ease",
}}
/>
{/* label */}
<text
x="50%"
y="50%"
textAnchor="middle"
dy=".3em"
fontSize="24"
fontWeight="600"
fontFamily="Satoshi"
color="#fff"
>
{value}
</text>
</svg>
);
}

View File

@ -0,0 +1,44 @@
import { useEffect } from "react";
type ConfettiBurstProps = {
count?: number;
};
export const ConfettiBurst = ({ count = 30 }: ConfettiBurstProps) => {
useEffect(() => {
const timeout = setTimeout(() => {
const container = document.getElementById("confetti-container");
if (container) container.innerHTML = "";
}, 1200);
return () => clearTimeout(timeout);
}, []);
return (
<div
id="confetti-container"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
{Array.from({ length: count }).map((_, i) => (
<span
key={i}
className="confetti"
style={{
left: `${Math.random() * 100}%`,
backgroundColor: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
animationDelay: `${Math.random() * 0.2}s`,
transform: `rotate(${Math.random() * 360}deg)`,
}}
/>
))}
</div>
);
};
const CONFETTI_COLORS = [
"#a855f7", // purple
"#6366f1", // indigo
"#ec4899", // pink
"#22c55e", // green
"#facc15", // yellow
];

View File

@ -0,0 +1,93 @@
import { useEffect, useRef } from "react";
declare global {
interface Window {
GGBApplet: any;
}
}
interface GraphProps {
width?: string;
height?: string;
commands?: string[];
defaultZoom?: number;
}
export function Graph({
width = "w-full",
height = "h-30",
commands = [],
defaultZoom = 1,
}: GraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const appRef = useRef<any>(null);
useEffect(() => {
if (!(window as any).GGBApplet) {
console.error("GeoGebra library not loaded");
return;
}
const applet = new window.GGBApplet(
{
appName: "graphing",
width: 480,
height: 320,
scale: 1.4,
showToolBar: false,
showAlgebraInput: false,
showMenuBar: false,
showResetIcon: false,
enableRightClick: false,
enableLabelDrags: false,
enableShiftDragZoom: true,
showZoomButtons: true,
appletOnLoad(api: any) {
appRef.current = api;
api.setPerspective("G");
api.setMode(0);
api.setAxesVisible(true, true);
api.setGridVisible(true);
api.setCoordSystem(-5, 5, -5, 5);
commands.forEach((command, i) => {
const name = `f${i}`;
api.evalCommand(`${name}: ${command}`);
api.setFixed(name, true);
});
// Inside appletOnLoad:
},
},
true,
);
applet.inject("ggb-container");
}, [commands, defaultZoom]);
useEffect(() => {
const resize = () => {
if (!containerRef.current || !appRef.current) return;
appRef.current.setSize(
containerRef.current.offsetWidth,
containerRef.current.offsetHeight,
);
};
window.addEventListener("resize", resize);
resize(); // initial resize
return () => window.removeEventListener("resize", resize);
}, []);
return (
<div ref={containerRef} className="h-[480] w-[320]">
<div id="ggb-container" className="w-full h-full" />
</div>
);
}

View File

@ -0,0 +1,634 @@
import { useRef, useEffect, useState, useCallback, useMemo } from "react";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface Equation {
/** A JS math expression in terms of x. e.g. "Math.sin(x)", "x**2 - 3" */
fn: string;
/** Hex or CSS color string */
color?: string;
/** Display label e.g. "y = x²" */
label?: string;
}
interface GraphPlotterProps {
equations: Equation[];
width?: number | string;
height?: number | string;
}
interface Intersection {
x: number;
y: number;
eqA: number;
eqB: number;
}
interface TooltipState {
screenX: number;
screenY: number;
mathX: number;
mathY: number;
eqA: number;
eqB: number;
}
// ─── Palette ──────────────────────────────────────────────────────────────────
const DEFAULT_COLORS = [
"#e05263", // crimson-rose
"#3b82f6", // blue
"#10b981", // emerald
"#f59e0b", // amber
"#a855f7", // violet
"#06b6d4", // cyan
"#f97316", // orange
];
// ─── Safe function evaluator ──────────────────────────────────────────────────
const buildFn = (expr: string): ((x: number) => number) => {
try {
// eslint-disable-next-line no-new-func
return new Function(
"x",
`"use strict"; try { return ${expr}; } catch(e) { return NaN; }`,
) as (x: number) => number;
} catch {
return () => NaN;
}
};
// ─── Intersection finder (bisection on sign changes) ─────────────────────────
const findIntersections = (
fns: Array<(x: number) => number>,
xMin: number,
xMax: number,
steps = 800,
): Intersection[] => {
const results: Intersection[] = [];
const dx = (xMax - xMin) / steps;
for (let a = 0; a < fns.length; a++) {
for (let b = a + 1; b < fns.length; b++) {
const diff = (x: number) => fns[a](x) - fns[b](x);
let prev = diff(xMin);
for (let i = 1; i <= steps; i++) {
const x1 = xMin + i * dx;
const cur = diff(x1);
if (isFinite(prev) && isFinite(cur) && prev * cur < 0) {
// Bisect
let lo = x1 - dx,
hi = x1;
for (let k = 0; k < 42; k++) {
const mid = (lo + hi) / 2;
const m = diff(mid);
if (Math.abs(m) < 1e-10) {
lo = hi = mid;
break;
}
if (m * diff(lo) < 0) hi = mid;
else lo = mid;
}
const rx = (lo + hi) / 2;
const ry = fns[a](rx);
if (isFinite(rx) && isFinite(ry)) {
// Dedupe
const dupe = results.some(
(p) => p.eqA === a && p.eqB === b && Math.abs(p.x - rx) < 1e-4,
);
if (!dupe) results.push({ x: rx, y: ry, eqA: a, eqB: b });
}
}
prev = cur;
}
}
}
return results;
};
// ─── Main Component ───────────────────────────────────────────────────────────
export const GraphPlotter = ({
equations,
width = "100%",
height = 480,
}: GraphPlotterProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Viewport state: origin in math coords + pixels-per-unit
const [viewport, setViewport] = useState({ cx: 0, cy: 0, scale: 60 });
const [canvasSize, setCanvasSize] = useState({ w: 600, h: 480 });
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [activeIntersections, setActiveIntersections] = useState<
Intersection[]
>([]);
// Pan state
const isPanning = useRef(false);
const lastPointer = useRef({ x: 0, y: 0 });
const lastPinchDist = useRef<number | null>(null);
// Build compiled functions
const compiledFns = useMemo(
() => equations.map((eq) => buildFn(eq.fn)),
[equations],
);
// Math → screen
const toScreen = useCallback(
(mx: number, my: number, vp = viewport, cs = canvasSize) => ({
sx: cs.w / 2 + (mx - vp.cx) * vp.scale,
sy: cs.h / 2 - (my - vp.cy) * vp.scale,
}),
[viewport, canvasSize],
);
// Screen → math
const toMath = useCallback(
(sx: number, sy: number, vp = viewport, cs = canvasSize) => ({
mx: vp.cx + (sx - cs.w / 2) / vp.scale,
my: vp.cy - (sy - cs.h / 2) / vp.scale,
}),
[viewport, canvasSize],
);
// Resize observer
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const e of entries) {
const { width: w, height: h } = e.contentRect;
setCanvasSize({ w: Math.floor(w), h: Math.floor(h) });
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// Compute intersections when fns or viewport changes
useEffect(() => {
if (compiledFns.length < 2) {
setActiveIntersections([]);
return;
}
const xMin = viewport.cx - canvasSize.w / (2 * viewport.scale);
const xMax = viewport.cx + canvasSize.w / (2 * viewport.scale);
const its = findIntersections(compiledFns, xMin, xMax);
setActiveIntersections(its);
}, [compiledFns, viewport, canvasSize]);
// ── Draw ──────────────────────────────────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const { w, h } = canvasSize;
canvas.width = w * devicePixelRatio;
canvas.height = h * devicePixelRatio;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.scale(devicePixelRatio, devicePixelRatio);
const vp = viewport;
const { sx: ox, sy: oy } = toScreen(0, 0, vp, canvasSize);
// Background
ctx.fillStyle = "#fafaf9";
ctx.fillRect(0, 0, w, h);
// Grid
const drawGrid = (
unit: number,
alpha: number,
lineWidth: number,
textSize?: number,
) => {
ctx.strokeStyle = `rgba(180,180,175,${alpha})`;
ctx.lineWidth = lineWidth;
ctx.beginPath();
const xStart = Math.floor((0 - ox) / (unit * vp.scale)) - 1;
const xEnd = Math.ceil((w - ox) / (unit * vp.scale)) + 1;
for (let i = xStart; i <= xEnd; i++) {
const sx = ox + i * unit * vp.scale;
ctx.moveTo(sx, 0);
ctx.lineTo(sx, h);
}
const yStart = Math.floor((oy - h) / (unit * vp.scale)) - 1;
const yEnd = Math.ceil(oy / (unit * vp.scale)) + 1;
for (let j = yStart; j <= yEnd; j++) {
const sy = oy - j * unit * vp.scale;
ctx.moveTo(0, sy);
ctx.lineTo(w, sy);
}
ctx.stroke();
// Labels
if (textSize) {
ctx.fillStyle = "#a8a29e";
ctx.font = `${textSize}px ui-monospace, monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
for (let i = xStart; i <= xEnd; i++) {
if (i === 0) continue;
const sx = ox + i * unit * vp.scale;
const val = i * unit;
const label = Number.isInteger(val) ? val.toString() : val.toFixed(1);
ctx.fillText(label, sx, oy + 4);
}
ctx.textAlign = "right";
ctx.textBaseline = "middle";
for (let j = yStart; j <= yEnd; j++) {
if (j === 0) continue;
const sy = oy - j * unit * vp.scale;
const val = j * unit;
const label = Number.isInteger(val) ? val.toString() : val.toFixed(1);
ctx.fillText(label, ox - 6, sy);
}
}
};
// Adaptive grid unit
const rawUnit = 1;
const targetPixels = 50;
const exp = Math.floor(Math.log10(targetPixels / vp.scale));
const unit = rawUnit * Math.pow(10, exp);
const subUnit = unit / 5;
drawGrid(subUnit, 0.35, 0.5);
drawGrid(unit, 0.7, 0.8, 10);
// Axes
ctx.strokeStyle = "#57534e";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, oy);
ctx.lineTo(w, oy);
ctx.moveTo(ox, 0);
ctx.lineTo(ox, h);
ctx.stroke();
// Arrow heads
const arrow = (x: number, y: number, dir: "r" | "u") => {
ctx.fillStyle = "#57534e";
ctx.beginPath();
if (dir === "r") {
ctx.moveTo(x, y);
ctx.lineTo(x - 8, y - 4);
ctx.lineTo(x - 8, y + 4);
} else {
ctx.moveTo(x, y);
ctx.lineTo(x - 4, y + 8);
ctx.lineTo(x + 4, y + 8);
}
ctx.closePath();
ctx.fill();
};
arrow(w, oy, "r");
arrow(ox, 0, "u");
// Origin label
ctx.fillStyle = "#a8a29e";
ctx.font = "10px ui-monospace, monospace";
ctx.textAlign = "right";
ctx.textBaseline = "top";
ctx.fillText("0", ox - 5, oy + 4);
// ── Plot each equation ────────────────────────────────────────────────────
const xMin = vp.cx - w / (2 * vp.scale);
const xMax = vp.cx + w / (2 * vp.scale);
const steps = w * 2;
const dx = (xMax - xMin) / steps;
equations.forEach((eq, idx) => {
const fn = compiledFns[idx];
const color = eq.color ?? DEFAULT_COLORS[idx % DEFAULT_COLORS.length];
ctx.strokeStyle = color;
ctx.lineWidth = 2.5;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.setLineDash([]);
ctx.beginPath();
let penDown = false;
let prevY = NaN;
for (let i = 0; i <= steps; i++) {
const mx = xMin + i * dx;
const my = fn(mx);
if (!isFinite(my)) {
penDown = false;
prevY = NaN;
continue;
}
// Break line on discontinuities (asymptotes)
if (Math.abs(my - prevY) > (h / vp.scale) * 2) {
penDown = false;
}
const { sx, sy } = toScreen(mx, my, vp, canvasSize);
if (!penDown) {
ctx.moveTo(sx, sy);
penDown = true;
} else {
ctx.lineTo(sx, sy);
}
prevY = my;
}
ctx.stroke();
});
// ── Intersection dots ─────────────────────────────────────────────────────
activeIntersections.forEach((pt) => {
const { sx, sy } = toScreen(pt.x, pt.y, vp, canvasSize);
if (sx < 0 || sx > w || sy < 0 || sy > h) return;
// Outer glow ring
ctx.beginPath();
ctx.arc(sx, sy, 9, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255,255,255,0.8)";
ctx.fill();
// Dot
ctx.beginPath();
ctx.arc(sx, sy, 5, 0, Math.PI * 2);
ctx.fillStyle = "#1c1917";
ctx.fill();
ctx.beginPath();
ctx.arc(sx, sy, 3, 0, Math.PI * 2);
ctx.fillStyle = "#fafaf9";
ctx.fill();
});
}, [
viewport,
canvasSize,
equations,
compiledFns,
activeIntersections,
toScreen,
]);
// ── Event handlers ────────────────────────────────────────────────────────
const zoom = useCallback(
(factor: number, pivotSx: number, pivotSy: number) => {
setViewport((vp) => {
const { mx, my } = toMath(pivotSx, pivotSy, vp, canvasSize);
const newScale = Math.max(5, Math.min(2000, vp.scale * factor));
return {
scale: newScale,
cx: mx - (pivotSx - canvasSize.w / 2) / newScale,
cy: my + (pivotSy - canvasSize.h / 2) / newScale,
};
});
},
[toMath, canvasSize],
);
const onWheel = useCallback(
(e: React.WheelEvent) => {
e.preventDefault();
const rect = canvasRef.current!.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
zoom(factor, sx, sy);
},
[zoom],
);
const onPointerDown = useCallback((e: React.PointerEvent) => {
isPanning.current = true;
lastPointer.current = { x: e.clientX, y: e.clientY };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!isPanning.current) return;
const dx = e.clientX - lastPointer.current.x;
const dy = e.clientY - lastPointer.current.y;
lastPointer.current = { x: e.clientX, y: e.clientY };
setViewport((vp) => ({
...vp,
cx: vp.cx - dx / vp.scale,
cy: vp.cy + dy / vp.scale,
}));
}, []);
const onPointerUp = useCallback((e: React.PointerEvent) => {
isPanning.current = false;
}, []);
// Touch pinch-to-zoom
const onTouchStart = useCallback((e: React.TouchEvent) => {
if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
lastPinchDist.current = Math.hypot(dx, dy);
}
}, []);
const onTouchMove = useCallback(
(e: React.TouchEvent) => {
if (e.touches.length === 2 && lastPinchDist.current !== null) {
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.hypot(dx, dy);
const factor = dist / lastPinchDist.current;
lastPinchDist.current = dist;
const rect = canvasRef.current!.getBoundingClientRect();
const pivotX =
(e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const pivotY =
(e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
zoom(factor, pivotX, pivotY);
}
},
[zoom],
);
// Tap to find nearest intersection
const onCanvasClick = useCallback(
(e: React.MouseEvent) => {
const rect = canvasRef.current!.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const { mx, my } = toMath(sx, sy, viewport, canvasSize);
// Find closest intersection within 20px
let best: Intersection | null = null;
let bestDist = Infinity;
for (const pt of activeIntersections) {
const { sx: px, sy: py } = toScreen(pt.x, pt.y, viewport, canvasSize);
const d = Math.hypot(px - sx, py - sy);
if (d < 24 && d < bestDist) {
best = pt;
bestDist = d;
}
}
if (best) {
const { sx: px, sy: py } = toScreen(
best.x,
best.y,
viewport,
canvasSize,
);
setTooltip({
screenX: px,
screenY: py,
mathX: best.x,
mathY: best.y,
eqA: best.eqA,
eqB: best.eqB,
});
} else {
setTooltip(null);
}
},
[activeIntersections, toMath, toScreen, viewport, canvasSize],
);
const fmt = (n: number) => {
if (Math.abs(n) < 1e-9) return "0";
if (Math.abs(n) >= 1e4 || (Math.abs(n) < 1e-3 && n !== 0))
return n.toExponential(3);
return parseFloat(n.toFixed(4)).toString();
};
const resetView = () => setViewport({ cx: 0, cy: 0, scale: 60 });
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div
ref={containerRef}
style={{ width, height, position: "relative", userSelect: "none" }}
className="rounded-2xl overflow-hidden border border-stone-200 shadow-md bg-stone-50 font-mono pt-32"
>
<canvas
ref={canvasRef}
style={{ display: "block", cursor: "crosshair", touchAction: "none" }}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onClick={onCanvasClick}
/>
{/* Equation legend */}
<div className="absolute top-3 left-3 flex flex-col gap-1.5">
{equations.map((eq, idx) => (
<div
key={idx}
className="flex items-center gap-2 px-2.5 py-1 rounded-lg bg-white/80 backdrop-blur-sm border border-stone-200 shadow-sm"
>
<span
className="block w-3 h-3 rounded-full shrink-0"
style={{
backgroundColor:
eq.color ?? DEFAULT_COLORS[idx % DEFAULT_COLORS.length],
}}
/>
<span className="text-[11px] text-stone-600 leading-none">
{eq.label ?? `y = ${eq.fn}`}
</span>
</div>
))}
</div>
{/* Controls */}
<div className="absolute top-3 right-3 flex flex-col gap-1.5">
<button
onClick={() => zoom(1.25, canvasSize.w / 2, canvasSize.h / 2)}
className="w-8 h-8 rounded-lg bg-white/90 border border-stone-200 shadow-sm text-stone-600 hover:bg-stone-100 transition text-lg flex items-center justify-center"
title="Zoom in"
>
+
</button>
<button
onClick={() => zoom(1 / 1.25, canvasSize.w / 2, canvasSize.h / 2)}
className="w-8 h-8 rounded-lg bg-white/90 border border-stone-200 shadow-sm text-stone-600 hover:bg-stone-100 transition text-lg flex items-center justify-center"
title="Zoom out"
>
</button>
<button
onClick={resetView}
className="w-8 h-8 rounded-lg bg-white/90 border border-stone-200 shadow-sm text-stone-500 hover:bg-stone-100 transition text-[10px] flex items-center justify-center font-sans"
title="Reset view"
>
</button>
</div>
{/* Intersection tooltip */}
{tooltip && (
<div
className="absolute z-10 pointer-events-none"
style={{
left: tooltip.screenX,
top: tooltip.screenY,
transform: "translate(-50%, -130%)",
}}
>
<div className="bg-stone-900 text-stone-100 text-[11px] px-3 py-2 rounded-xl shadow-xl border border-stone-700 whitespace-nowrap">
<div className="font-semibold mb-0.5 text-stone-300 text-[10px] tracking-wide uppercase">
Intersection
</div>
<div>
x ={" "}
<span className="text-amber-300 font-bold">
{fmt(tooltip.mathX)}
</span>
</div>
<div>
y ={" "}
<span className="text-amber-300 font-bold">
{fmt(tooltip.mathY)}
</span>
</div>
<div className="text-stone-500 text-[9px] mt-1">
eq {tooltip.eqA + 1} eq {tooltip.eqB + 1}
</div>
</div>
{/* Arrow */}
<div className="flex justify-center">
<div className="w-2 h-2 bg-stone-900 rotate-45 -mt-1 border-r border-b border-stone-700" />
</div>
</div>
)}
{/* Dismiss tooltip on background click hint */}
{tooltip && (
<button
className="absolute inset-0 w-full h-full bg-transparent"
onClick={() => setTooltip(null)}
style={{ zIndex: 5 }}
/>
)}
</div>
);
};

View File

@ -0,0 +1,22 @@
export const LeaderboardRowSkeleton = () => {
return (
<div className="flex justify-between items-center animate-pulse">
<div className="flex items-center gap-3">
{/* Rank / Trophy */}
<div className="w-12 h-12 rounded-full bg-gray-200" />
{/* Avatar */}
<div className="w-12 h-12 rounded-full bg-gray-300" />
{/* Name */}
<div className="h-4 w-32 bg-gray-200 rounded" />
</div>
{/* XP */}
<div className="flex items-center gap-2">
<div className="h-4 w-10 bg-gray-200 rounded" />
<div className="w-5 h-5 rounded bg-gray-200" />
</div>
</div>
);
};

View File

@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "../components/ui/dialog";
import { api } from "../utils/api";
import { useAuthStore } from "../stores/authStore";
interface LessonModalProps {
lessonId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const LessonModal = ({
lessonId,
open,
onOpenChange,
}: LessonModalProps) => {
const user = useAuthStore((state) => state.user);
const [loading, setLoading] = useState(false);
const [lesson, setLesson] = useState<any>(null);
useEffect(() => {
if (!open || !lessonId || !user) return;
const fetchLesson = async () => {
try {
setLoading(true);
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const parsed = JSON.parse(authStorage) as {
state?: { token?: string };
};
const token = parsed.state?.token;
if (!token) return;
const response = await api.fetchLessonById(token, lessonId);
setLesson(response);
} catch (err) {
console.error("Failed to fetch lesson", err);
} finally {
setLoading(false);
}
};
fetchLesson();
}, [open, lessonId, user]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
{loading && (
<div className="py-12 text-center text-muted-foreground">
Loading lesson...
</div>
)}
<DialogHeader>
<DialogTitle>{lesson ? lesson.title : "Lesson details"}</DialogTitle>
</DialogHeader>
{!loading && lesson && (
<div className="space-y-4">
{lesson.video_url && (
<video
src={lesson.video_url}
controls
className="w-full rounded-lg"
/>
)}
<h2 className="font-satoshi-bold text-xl">
{lesson ? lesson.title : "Lesson details"}
</h2>
<p className="text-sm text-muted-foreground">
{lesson.description}
</p>
<p className="text-sm text-muted-foreground">{lesson.content}</p>
</div>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,11 @@
import { Card, CardContent } from "./ui/card";
export const LessonSkeleton = () => (
<Card className="py-0 pb-5 rounded-4xl overflow-hidden animate-pulse">
<div className="w-full h-48 bg-muted" />
<CardContent className="space-y-2 pt-4">
<div className="h-5 w-2/3 bg-muted rounded" />
<div className="h-4 w-1/2 bg-muted rounded" />
</CardContent>
</Card>
);

View File

@ -0,0 +1,305 @@
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../components/ui/card";
import { api } from "../utils/api";
import { useAuthToken } from "../hooks/useAuthToken";
import {
TrendingUp,
BookOpen,
Calculator,
Loader2,
ChevronDown,
ChevronUp,
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────────
interface SectionPrediction {
score: number;
range_min: number;
range_max: number;
confidence: string;
}
interface PredictedScoreResponse {
total_score: number;
math_prediction: SectionPrediction;
rw_prediction: SectionPrediction;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const confidenceConfig: Record<
string,
{ label: string; color: string; bg: string; dot: string }
> = {
high: {
label: "High confidence",
color: "text-emerald-700",
bg: "bg-emerald-50 border-emerald-200",
dot: "bg-emerald-500",
},
medium: {
label: "Medium confidence",
color: "text-amber-700",
bg: "bg-amber-50 border-amber-200",
dot: "bg-amber-400",
},
low: {
label: "Low confidence",
color: "text-rose-700",
bg: "bg-rose-50 border-rose-200",
dot: "bg-rose-400",
},
};
const getConfidenceStyle = (confidence: string) =>
confidenceConfig[confidence.toLowerCase()] ?? {
label: confidence,
color: "text-gray-600",
bg: "bg-gray-50 border-gray-200",
dot: "bg-gray-400",
};
const useCountUp = (target: number, duration = 900) => {
const [value, setValue] = useState(0);
useEffect(() => {
if (!target) return;
let start: number | null = null;
const step = (ts: number) => {
if (!start) start = ts;
const progress = Math.min((ts - start) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setValue(Math.floor(eased * target));
if (progress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}, [target, duration]);
return value;
};
// ─── Expanded section detail ──────────────────────────────────────────────────
const SectionDetail = ({
label,
icon: Icon,
prediction,
accentClass,
}: {
label: string;
icon: React.ElementType;
prediction: SectionPrediction;
accentClass: string;
}) => {
const conf = getConfidenceStyle(prediction.confidence);
return (
<div className="flex flex-col gap-2 rounded-2xl border border-gray-100 bg-gray-50 px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`p-1.5 rounded-lg ${accentClass}`}>
<Icon size={14} className="text-white" />
</div>
<span className="font-satoshi-medium text-sm text-gray-700">
{label}
</span>
</div>
<span
className={`flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full border font-satoshi ${conf.bg} ${conf.color}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${conf.dot}`} />
{conf.label}
</span>
</div>
<div className="flex items-end justify-between mt-1">
<span className="font-satoshi-bold text-2xl text-gray-900">
{prediction.score}
</span>
<span className="font-satoshi text-xs text-gray-400 mb-1">
Range:{" "}
<span className="text-gray-600 font-satoshi-medium">
{prediction.range_min}{prediction.range_max}
</span>
</span>
</div>
{/* Range bar */}
<div className="relative h-1.5 rounded-full bg-gray-200 mt-1">
<div
className={`absolute h-1.5 rounded-full ${accentClass} opacity-60`}
style={{
left: `${((prediction.range_min - 200) / (800 - 200)) * 100}%`,
right: `${100 - ((prediction.range_max - 200) / (800 - 200)) * 100}%`,
}}
/>
<div
className={`absolute w-2.5 h-2.5 rounded-full border-2 border-white ${accentClass} -top-0.5 shadow-sm`}
style={{
left: `calc(${((prediction.score - 200) / (800 - 200)) * 100}% - 5px)`,
}}
/>
</div>
<div className="flex justify-between text-[10px] text-gray-300 font-satoshi mt-0.5">
<span>200</span>
<span>800</span>
</div>
</div>
);
};
// ─── Main component ───────────────────────────────────────────────────────────
export const PredictedScoreCard = () => {
const token = useAuthToken();
const [data, setData] = useState<PredictedScoreResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (!token) return;
(async () => {
try {
setLoading(true);
const result = await api.fetchPredictedScore(token);
setData(result);
} catch (err) {
setError("Couldn't load your predicted score.");
console.error(err);
} finally {
setLoading(false);
}
})();
}, [token]);
const animatedTotal = useCountUp(data?.total_score ?? 0, 1000);
return (
<Card className="w-full border border-gray-200 shadow-sm overflow-hidden">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="font-satoshi-bold text-lg text-gray-900">
Predicted SAT Score
</CardTitle>
<CardDescription className="font-satoshi text-sm text-gray-400 mt-0.5">
Based on your practice performance
</CardDescription>
</div>
<div className="p-2 rounded-xl bg-purple-50 border border-purple-100">
<TrendingUp size={18} className="text-purple-500" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 size={26} className="animate-spin text-purple-400" />
</div>
)}
{error && !loading && (
<p className="font-satoshi text-sm text-rose-500 text-center py-4">
{error}
</p>
)}
{data && !loading && (
<>
{/* ── Collapsed view: big numbers only ── */}
<div className="flex items-center justify-between">
{/* Total */}
<div className="flex flex-col">
<span className="font-satoshi text-lg text-gray-400 mb-0.5">
Total
</span>
<span className="font-satoshi-bold text-6xl text-gray-900 leading-none">
{animatedTotal}
</span>
<span className="font-satoshi text-[18px] text-gray-300 mt-1">
out of 1600
</span>
</div>
<div className="h-12 w-px bg-gray-100" />
{/* Math */}
<div className="flex flex-col items-center">
<div className="flex items-center gap-1 mb-0.5">
<Calculator size={16} className="text-violet-400" />
<span className="font-satoshi text-sm text-gray-400">
Math
</span>
</div>
<span className="font-satoshi-bold text-3xl text-gray-900 leading-none">
{data.math_prediction.score}
</span>
<span className="font-satoshi text-[12px] text-gray-300 mt-1">
out of 800
</span>
</div>
<div className="h-12 w-px bg-gray-100" />
{/* R&W */}
<div className="flex flex-col items-center">
<div className="flex items-center gap-1 mb-0.5">
<BookOpen size={16} className="text-sky-400" />
<span className="font-satoshi text-sm text-gray-400">
R&W
</span>
</div>
<span className="font-satoshi-bold text-3xl text-gray-900 leading-none">
{data.rw_prediction.score}
</span>
<span className="font-satoshi text-[12px] text-gray-300 mt-1">
out of 800
</span>
</div>
</div>
{/* ── Expand toggle ── */}
<button
onClick={() => setExpanded((p) => !p)}
className="w-full flex items-center justify-center gap-1.5 py-2 text-xs font-satoshi-medium text-gray-400 hover:text-purple-500 transition-colors"
>
{expanded ? (
<>
<ChevronUp size={14} /> Less detail
</>
) : (
<>
<ChevronDown size={14} /> More detail
</>
)}
</button>
{/* ── Expanded: range bars + confidence ── */}
{expanded && (
<div className="space-y-3 pt-1">
<SectionDetail
label="Math"
icon={Calculator}
prediction={data.math_prediction}
accentClass="bg-violet-500"
/>
<SectionDetail
label="Reading & Writing"
icon={BookOpen}
prediction={data.rw_prediction}
accentClass="bg-sky-500"
/>
</div>
)}
</>
)}
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,232 @@
import { motion, AnimatePresence } from "framer-motion";
import { useEffect, useMemo } from "react";
import { Search, X } from "lucide-react";
import type { PracticeSheet } from "../types/sheet";
import { useNavigate } from "react-router-dom";
import type { SearchItem } from "../types/search";
import { formatGroupTitle } from "../lib/utils";
interface Props {
sheets: PracticeSheet[];
onClose: () => void;
searchQuery: string;
setSearchQuery: (value: string) => void;
}
const navigationItems: SearchItem[] = [
{
type: "route",
title: "Hard Test Modules",
description: "Access advanced SAT modules",
route: "/student/hard-test-modules",
group: "Pages",
},
{
type: "route",
title: "Targeted Practice",
description: "Focus on what matters",
route: "/student/practice/targeted-practice",
group: "Pages",
},
{
type: "route",
title: "Drills",
description: "Train speed and accuracy",
route: "/student/practice/drills",
group: "Pages",
},
{
type: "route",
title: "Leaderboard",
description: "View student rankings",
route: "/student/rewards",
group: "Pages",
},
{
type: "route",
title: "Practice",
description: "See how you can practice",
route: "/student/practice",
group: "Pages",
},
{
type: "route",
title: "Lessons",
description: "Watch detailed lessons on SAT techniques",
route: "/student/lessons",
group: "Pages",
},
{
type: "route",
title: "Profile",
description: "View your profile",
route: "/student/profile",
group: "Pages",
},
];
const highlightText = (text: string, query: string) => {
if (!query.trim()) return text;
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
const parts = text.split(regex);
return parts.map((part, index) => {
const isMatch = part.toLowerCase() === query.toLowerCase();
return isMatch ? (
<motion.span
key={index}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
className="bg-purple-200 text-purple-900 px-1 rounded-md"
>
{part}
</motion.span>
) : (
part
);
});
};
export const SearchOverlay = ({
sheets,
onClose,
searchQuery,
setSearchQuery,
}: Props) => {
const navigate = useNavigate();
const searchItems = useMemo<SearchItem[]>(() => {
const sheetItems = sheets.map((sheet) => ({
type: "sheet",
id: sheet.id,
title: sheet.title,
description: sheet.description,
route: `/student/practice/${sheet.id}`,
group: formatGroupTitle(sheet.user_status), // 👈 reuse your grouping
}));
return [...navigationItems, ...sheetItems];
}, [sheets]);
// Close on ESC
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
const groupedResults = useMemo(() => {
if (!searchQuery.trim()) return {};
const q = searchQuery.toLowerCase();
const filtered = searchItems.filter((item) => {
const title = item.title?.toLowerCase() || "";
const description = item.description?.toLowerCase() || "";
return title.includes(q) || description.includes(q);
});
return filtered.reduce<Record<string, SearchItem[]>>((acc, item) => {
if (!acc[item.group]) {
acc[item.group] = [];
}
acc[item.group].push(item);
return acc;
}, {});
}, [searchQuery, searchItems]);
return (
<AnimatePresence>
<motion.div
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm flex flex-col items-center pt-24 px-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
{/* Search Box */}
<motion.div
initial={{ y: -40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -40, opacity: 0 }}
transition={{ type: "spring", stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-2xl bg-white rounded-3xl shadow-2xl p-6"
>
<div className="flex items-center gap-3">
<Search size={20} />
<input
autoFocus
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="flex-1 outline-none font-satoshi text-lg"
/>
<button onClick={onClose}>
<X size={20} />
</button>
</div>
{/* Results */}
<div className="mt-6 max-h-96 overflow-y-auto space-y-6">
{/* {!searchQuery && (
<p className="font-satoshi text-gray-500">
Start typing to search...
</p>
)} */}
{searchQuery.length === 0 ? (
<p className="text-gray-400 font-satoshi">
Start typing to search...
</p>
) : Object.keys(groupedResults).length === 0 ? (
<p className="text-gray-400 font-satoshi">No results found.</p>
) : (
Object.entries(groupedResults).map(([group, items]) => (
<div key={group}>
<p className="text-xs uppercase tracking-wider text-gray-400 font-satoshi mb-3">
{group}
</p>
<div className="space-y-2">
{items.map((item, index) => (
<div
key={index}
onClick={() => {
onClose();
navigate(item.route!);
}}
className="p-4 rounded-2xl hover:bg-gray-100 cursor-pointer transition"
>
<p className="font-satoshi-medium">
{highlightText(item.title, searchQuery)}
</p>
{item.description && (
<p className="text-sm text-gray-500">
{highlightText(item.description, searchQuery)}
</p>
)}
<p className="text-xs text-purple-500 mt-1">
{item.type === "route" ? "" : "Practice Sheet"}
</p>
</div>
))}
</div>
</div>
))
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
};

View File

@ -0,0 +1,31 @@
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

246
src/components/ui/field.tsx Normal file
View File

@ -0,0 +1,246 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,29 @@
import * as React from "react";
import { Progress as ProgressPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-purple-100/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-black h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

143
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,726 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

19
src/hooks/use-mobile.ts Normal file
View File

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@ -281,3 +281,38 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 300ms ease-out forwards;
}
.confetti {
position: absolute;
top: -10px;
width: 8px;
height: 14px;
opacity: 0.9;
animation: confetti-fall 1.2s ease-out forwards;
}
@keyframes confetti-fall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(220px) rotate(720deg);
opacity: 0;
}
}

View File

@ -81,3 +81,33 @@ export const formatTime = (seconds: number) => {
const s = seconds % 60; const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`; return `${m}:${s.toString().padStart(2, "0")}`;
}; };
export const slideVariants = {
initial: (direction: number) => ({
x: direction > 0 ? 100 : -100,
opacity: 0,
}),
animate: {
x: 0,
opacity: 1,
transition: { duration: 0.35 },
},
exit: (direction: number) => ({
x: direction > 0 ? -100 : 100,
opacity: 0,
transition: { duration: 0.25 },
}),
};
export const formatGroupTitle = (status: string) => {
switch (status) {
case "IN_PROGRESS":
return "In Progress";
case "NOT_STARTED":
return "Not Started";
case "COMPLETED":
return "Completed";
default:
return status;
}
};

View File

@ -1,10 +1,10 @@
import { StrictMode } from 'react' import { StrictMode } from "react";
import { createRoot } from 'react-dom/client' import { createRoot } from "react-dom/client";
import './index.css' import "./index.css";
import App from './App.tsx' import App from "./App.tsx";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,
) );

View File

@ -0,0 +1,71 @@
import { List, SquarePen, DecimalsArrowRight, MapPin } from "lucide-react";
import { Progress } from "../../components/ui/progress";
import { Button } from "../../components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "../../components/ui/card";
import { Field, FieldLabel } from "../../components/ui/field";
import { CircularProgress } from "../../components/CircularProgress";
export const Analytics = () => {
return (
<main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
<h1 className="font-satoshi-bold text-3xl text-center tracking-tight">
Analytics
</h1>
<section className="flex w-full gap-3 justify-between">
<Card className="w-1/3 relative bg-linear-to-br from-purple-600 to-purple-700 rounded-4xl">
<div className="space-y-4">
<CardContent className="md:w-full space-y-4 flex flex-col items-center justify-center h-50">
<MapPin size={60} color="white" />
<h1 className="text-4xl font-satoshi-bold text-white flex">
<span>145</span> <span className="text-xl">th</span>
</h1>
</CardContent>
</div>
<div className="overflow-hidden opacity-0 -rotate-45 absolute -top-2 -right-30 ">
<DecimalsArrowRight size={380} color="white" />
</div>
</Card>
<Card
className="w-2/3 relative bg-linear-to-br from-gray-100 to-gray-300 rounded-4xl
flex-row"
>
<div className="space-y-4">
<CardHeader className="md:w-full">
<CardTitle className="font-satoshi-bold tracking-tight text-3xl ">
Details
</CardTitle>
</CardHeader>
<CardContent className="md:w-full space-y-4"></CardContent>
<CardFooter className="flex justify-between"></CardFooter>
</div>
<div className="overflow-hidden opacity-30 -rotate-45 absolute -top-2 -right-30 ">
<DecimalsArrowRight size={380} color="white" />
</div>
</Card>
</section>
<section>
<Card>
<CardContent>
<Field className="w-full max-w-sm">
<FieldLabel htmlFor="progress-upload">
<span className="font-satoshi text-xl">Score</span>
<span className="ml-auto font-satoshi">
<span className="text-5xl">854</span>
<span className="text-lg">/1600</span>
</span>
</FieldLabel>
<Progress value={55} id="progress-upload" max={100} />
</Field>
</CardContent>
</Card>
</section>
</main>
);
};

View File

@ -6,7 +6,7 @@ import {
TabsContent, TabsContent,
} from "../../components/ui/tabs"; } from "../../components/ui/tabs";
import { useAuthStore } from "../../stores/authStore"; import { useAuthStore } from "../../stores/authStore";
import { CheckCircle, Search } from "lucide-react"; import { CheckCircle, Flame, Search, Zap } from "lucide-react";
import { api } from "../../utils/api"; import { api } from "../../utils/api";
import { import {
Card, Card,
@ -21,18 +21,28 @@ import { Button } from "../../components/ui/button";
import type { PracticeSheet } from "../../types/sheet"; import type { PracticeSheet } from "../../types/sheet";
import { formatStatus } from "../../lib/utils"; import { formatStatus } from "../../lib/utils";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { SearchOverlay } from "../../components/SearchOverlay";
import { PredictedScoreCard } from "../../components/PredictedScoreCard";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "../../components/ui/avatar";
import { useExamConfigStore } from "../../stores/useExamConfigStore";
export const Home = () => { export const Home = () => {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const navigate = useNavigate(); const navigate = useNavigate();
const userXp = useExamConfigStore.getState().userXp;
// const logout = useAuthStore((state) => state.logout);
// const navigate = useNavigate();
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]); const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]); const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
const [inProgressSheets, setInProgressSheets] = useState<PracticeSheet[]>([]); const [inProgressSheets, setInProgressSheets] = useState<PracticeSheet[]>([]);
const [completedSheets, setCompletedSheets] = useState<PracticeSheet[]>([]); const [completedSheets, setCompletedSheets] = useState<PracticeSheet[]>([]);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
const sortPracticeSheets = (sheets: PracticeSheet[]) => { const sortPracticeSheets = (sheets: PracticeSheet[]) => {
const notStarted = sheets.filter( const notStarted = sheets.filter(
@ -68,7 +78,6 @@ export const Home = () => {
} }
const sheets = await api.getPracticeSheets(token, 1, 10); const sheets = await api.getPracticeSheets(token, 1, 10);
setPracticeSheets(sheets.data); setPracticeSheets(sheets.data);
console.log("All Practice Sheets: ", sheets.data);
sortPracticeSheets(sheets.data); sortPracticeSheets(sheets.data);
} catch (error) { } catch (error) {
console.error("Error fetching practice sheets:", error); console.error("Error fetching practice sheets:", error);
@ -78,25 +87,62 @@ export const Home = () => {
fetchPracticeSheets(); fetchPracticeSheets();
}, [user]); }, [user]);
const handleStartPractice = (sheetId: string) => { const handleStartPracticeSheet = (sheetId: string) => {
navigate(`/student/practice/${sheetId}`); navigate(`/student/practice/${sheetId}`);
}; };
return ( return (
<main className="min-h-screen bg-gray-50 flex flex-col gap-12 max-w-full mx-auto px-8 sm:px-6 lg:px-8 py-12"> <main className="min-h-screen space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-12">
<h1 className="text-4xl font-satoshi-bold tracking-tight text-gray-800 text-center"> <header className="flex items-center gap-3 justify-between">
<div className="flex gap-3">
<Avatar className="w-12 h-12">
<AvatarImage src={user?.avatar_url} />
<AvatarFallback className="font-satoshi-bold bg-linear-to-br from-indigo-400 to-indigo-500 uppercase text-lg text-white">
{user?.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<h1 className="text-xl font-satoshi-bold tracking-tight text-gray-800 text-center">
Welcome, {user?.name || "Student"} Welcome, {user?.name || "Student"}
</h1> </h1>
<h4 className="text-sm font-satoshi-bold text-indigo-500 ">
{user?.role === "STUDENT"
? "Student"
: user?.role === "ADMIN"
? "Admin"
: "Taecher"}
</h4>
</div>
</div>
<div className="flex gap-3">
<div className="rounded-full w-fit flex items-center gap-2">
<Flame size={20} className="text-red-500 fill-amber-200" />
<span className="font-satoshi-bold text-md">5</span>
</div>
<div className="rounded-full w-fit flex items-center gap-2">
<Zap size={20} className="text-lime-500 fill-lime-200" />
<span className="font-satoshi-bold text-md">{userXp}</span>
</div>
</div>
</header>
<PredictedScoreCard />
<h1 className="font-satoshi-bold text-2xl tracking-tight">
What are you looking for?
</h1>
<section className="relative w-full"> <section className="relative w-full">
<input <input
type="text" onFocus={() => setIsSearchOpen(true)}
placeholder="Search..." placeholder="Search practice sheets..."
className="font-satoshi w-full pl-10 pr-4 py-3 border border-gray-300 rounded-2xl shadow-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" readOnly
className="font-satoshi w-full pl-10 pr-4 py-3 border border-gray-300 rounded-2xl shadow-sm cursor-pointer"
/> />
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"> <div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Search size={22} color="gray" /> <Search size={22} color="gray" />
</div> </div>
</section> </section>
<section className="space-y-4"> <section className="space-y-4">
<h1 className="font-satoshi-bold text-2xl tracking-tight"> <h1 className="font-satoshi-bold text-2xl tracking-tight">
Pick up where you left off Pick up where you left off
@ -105,7 +151,7 @@ export const Home = () => {
inProgressSheets.map((sheet) => ( inProgressSheets.map((sheet) => (
<Card <Card
key={sheet?.id} key={sheet?.id}
className="rounded-4xl border bg-purple-50/70 border-purple-500" className="rounded-4xl border bg-indigo-50/70 border-indigo-500"
> >
<CardHeader> <CardHeader>
<CardTitle className="font-satoshi-medium text-xl"> <CardTitle className="font-satoshi-medium text-xl">
@ -116,7 +162,7 @@ export const Home = () => {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex justify-between"> <CardContent className="flex justify-between">
<p className="font-satoshi text-sm border px-2 rounded-full bg-purple-500 text-white py-1"> <p className="font-satoshi text-sm border px-2 rounded-full bg-indigo-500 text-white py-1">
{formatStatus(sheet?.user_status)} {formatStatus(sheet?.user_status)}
</p> </p>
<Badge <Badge
@ -133,9 +179,9 @@ export const Home = () => {
</CardContent> </CardContent>
<CardFooter> <CardFooter>
<Button <Button
onClick={() => handleStartPractice(sheet?.id)} onClick={() => handleStartPracticeSheet(sheet?.id)}
variant="outline" variant="outline"
className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 text-white" className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-indigo-500 to-indigo-600 text-white"
> >
Resume Resume
</Button> </Button>
@ -155,19 +201,19 @@ export const Home = () => {
<TabsList className="bg-transparent p-0 w-full"> <TabsList className="bg-transparent p-0 w-full">
<TabsTrigger <TabsTrigger
value="all" value="all"
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:font-satoshi-medium data-[state=active]:border-b-purple-800 data-[state=active]:text-purple-800" className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:font-satoshi-medium data-[state=active]:border-b-indigo-800 data-[state=active]:text-indigo-800"
> >
All All
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="NOT_STARTED" value="NOT_STARTED"
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:border-b-purple-800 data-[state=active]:text-purple-800" className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:border-b-indigo-800 data-[state=active]:text-indigo-800"
> >
Not Started Not Started
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="COMPLETED" value="COMPLETED"
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:border-b-purple-800 data-[state=active]:text-purple-800" className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:border-b-indigo-800 data-[state=active]:text-indigo-800"
> >
Completed Completed
</TabsTrigger> </TabsTrigger>
@ -203,9 +249,9 @@ export const Home = () => {
</CardContent> </CardContent>
<CardFooter> <CardFooter>
<Button <Button
onClick={() => handleStartPractice(sheet?.id)} onClick={() => handleStartPracticeSheet(sheet?.id)}
variant="outline" variant="outline"
className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 text-white" className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-indigo-500 to-indigo-600 text-white"
> >
Start Start
</Button> </Button>
@ -250,7 +296,7 @@ export const Home = () => {
<CardFooter> <CardFooter>
<Button <Button
variant="outline" variant="outline"
className="font-satoshi w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 rounded-3xl text-white" className="font-satoshi w-full text-lg py-6 bg-linear-to-br from-indigo-500 to-indigo-600 rounded-3xl text-white"
> >
Start Start
</Button> </Button>
@ -291,7 +337,7 @@ export const Home = () => {
<CardFooter> <CardFooter>
<Button <Button
variant="outline" variant="outline"
className="font-satoshi w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 rounded-3xl text-white" className="font-satoshi w-full text-lg py-6 bg-linear-to-br from-indigo-500 to-indigo-600 rounded-3xl text-white"
> >
Start Start
</Button> </Button>
@ -316,35 +362,46 @@ export const Home = () => {
</h1> </h1>
<section className="space-y-4 "> <section className="space-y-4 ">
<div className="flex gap-2"> <div className="flex gap-2">
<CheckCircle size={24} color="#AD45FF" /> <CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
<p className="font-satoshi text-md"> <p className="font-satoshi text-md">
Practice regularly with official SAT materials Practice regularly with official SAT materials
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle size={24} color="#AD45FF" /> <CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
<p className="font-satoshi text-md"> <p className="font-satoshi text-md">
Review your mistakes and learn from them Review your mistakes and learn from them
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle size={24} color="#AD45FF" /> <CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
<p className="font-satoshi text-md">Focus on your weak areas</p> <p className="font-satoshi text-md">Focus on your weak areas</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle size={24} color="#AD45FF" /> <CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
<p className="font-satoshi text-md"> <p className="font-satoshi text-md">
Take full-length practice tests Take full-length practice tests
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle size={24} color="#AD45FF" /> <CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
<p className="font-satoshi text-md"> <p className="font-satoshi text-md">
Get plenty of rest before the test day Get plenty of rest before the test day
</p> </p>
</div> </div>
</section> </section>
</section> </section>
{isSearchOpen && (
<SearchOverlay
sheets={practiceSheets}
onClose={() => {
setIsSearchOpen(false);
setSearchQuery("");
}}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
)}
</main> </main>
); );
}; };

View File

@ -1,4 +1,5 @@
// import { useAuthStore } from "../../stores/authStore"; import { useAuthStore } from "../../stores/authStore";
import { useEffect, useState } from "react";
import { import {
Card, Card,
CardHeader, CardHeader,
@ -12,9 +13,52 @@ import {
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "../../components/ui/tabs"; } from "../../components/ui/tabs";
import { api } from "../../utils/api";
import { type Lesson } from "../../types/lesson";
import { LessonSkeleton } from "../../components/LessonSkeleton";
import { LessonModal } from "../../components/LessonModal";
export const Lessons = () => { export const Lessons = () => {
// const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const [lessons, setLessons] = useState<Lesson[]>([]);
const [lessonLoading, setLessonlLoading] = useState(true);
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const handleLessonClick = (lessonId: string) => {
setSelectedLessonId(lessonId);
setIsModalOpen(true);
};
useEffect(() => {
const fetchAllLessons = async () => {
if (!user) return;
try {
setLessonlLoading(true);
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const parsed = JSON.parse(authStorage) as {
state?: { token?: string };
};
const token = parsed.state?.token;
if (!token) return;
const response = await api.fetchAllLessons(token);
setLessonlLoading(false);
setLessons(response.data);
} catch (error) {
setLessonlLoading(false);
console.error("Error fetching lessons:", error);
}
};
fetchAllLessons();
}, [user]);
return ( return (
<main className="min-h-screen space-y-6 max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8"> <main className="min-h-screen space-y-6 max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8">
@ -43,61 +87,99 @@ export const Lessons = () => {
</TabsList> </TabsList>
<TabsContent value="rw" className="pt-4"> <TabsContent value="rw" className="pt-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="py-0 pb-5 rounded-4xl overflow-hidden"> {lessonLoading && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<LessonSkeleton key={i} />
))}
</div>
)}
{!lessonLoading && lessons.length === 0 && (
<div className="text-center text-muted-foreground py-12">
No lessons available
</div>
)}
{!lessonLoading && lessons.length > 0 && (
<div className="grid grid-cols-1 gap-4">
{lessons.map((lesson) => (
<Card
key={lesson.id}
onClick={() => handleLessonClick(lesson.id)}
className="py-0 pb-5 rounded-4xl overflow-hidden"
>
<CardHeader className="w-full py-0 px-0"> <CardHeader className="w-full py-0 px-0">
<img <img
src="https://placehold.co/600x400" src={lesson.thumbnail_url}
alt="Video Thumbnail" alt={lesson.title}
className="w-full h-auto rounded" className="w-full h-auto"
/> />
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<CardTitle>Video Title</CardTitle> <CardTitle>{lesson.title}</CardTitle>
<CardDescription>Video Description</CardDescription> <CardDescription>{lesson.topic.name}</CardDescription>
</CardContent> </CardContent>
</Card> </Card>
<Card className="py-0 pb-5 rounded-4xl overflow-hidden"> ))}
<CardHeader className="w-full py-0 px-0"> </div>
<img )}
src="https://placehold.co/600x400" <LessonModal
alt="Video Thumbnail" open={isModalOpen}
className="w-full h-auto rounded" lessonId={selectedLessonId}
onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) setSelectedLessonId(null);
}}
/> />
</CardHeader>
<CardContent className="space-y-2">
<CardTitle>Video Title</CardTitle>
<CardDescription>Video Description</CardDescription>
</CardContent>
</Card>
<Card className="py-0 pb-5 rounded-4xl overflow-hidden">
<CardHeader className="w-full py-0 px-0">
<img
src="https://placehold.co/600x400"
alt="Video Thumbnail"
className="w-full h-auto rounded"
/>
</CardHeader>
<CardContent className="space-y-2">
<CardTitle>Video Title</CardTitle>
<CardDescription>Video Description</CardDescription>
</CardContent>
</Card>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="math" className="pt-4"> <TabsContent value="math" className="pt-4">
<Card className="py-0 pb-8 rounded-4xl overflow-hidden"> {lessonLoading && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<LessonSkeleton key={i} />
))}
</div>
)}
{!lessonLoading && lessons.length === 0 && (
<div className="text-center text-muted-foreground py-12">
No lessons available
</div>
)}
{!lessonLoading && lessons.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{lessons.map((lesson) => (
<Card
key={lesson.id}
onClick={() => handleLessonClick(lesson.id)}
className="py-0 pb-5 rounded-4xl overflow-hidden"
>
<CardHeader className="w-full py-0 px-0"> <CardHeader className="w-full py-0 px-0">
<img <img
src="https://placehold.co/600x400" src={lesson.thumbnail_url}
alt="Video Thumbnail" alt={lesson.title}
className="w-full h-auto rounded" className="w-full h-auto"
/> />
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<CardTitle>Video Title</CardTitle> <CardTitle>{lesson.title}</CardTitle>
<CardDescription>Video Description</CardDescription> <CardDescription>{lesson.topic.name}</CardDescription>
</CardContent> </CardContent>
</Card> </Card>
))}
</div>
)}
<LessonModal
open={isModalOpen}
lessonId={selectedLessonId}
onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) setSelectedLessonId(null);
}}
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</section> </section>

View File

@ -17,22 +17,27 @@ import {
CardTitle, CardTitle,
} from "../../components/ui/card"; } from "../../components/ui/card";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { useNavigate } from "react-router-dom";
import { useExamConfigStore } from "../../stores/useExamConfigStore";
export const Practice = () => { export const Practice = () => {
const navigate = useNavigate();
const userXp = useExamConfigStore.getState().userXp;
return ( return (
<main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4"> <div className="px-8 py-8 space-y-4">
<header className="flex justify-between items-center"> <header className="flex justify-between items-center ">
<div className="w-fit bg-linear-to-br from-purple-500 to-purple-600 p-3 rounded-2xl"> <div className="w-fit bg-linear-to-br from-indigo-500 to-indigo-600 p-3 rounded-2xl">
<BookOpen size={20} color="white" /> <BookOpen size={20} color="white" />
</div> </div>
<div className="bg-purple-100 rounded-full w-fit py-2 px-4 flex items-center gap-2"> <div className="bg-indigo-100 rounded-full w-fit py-2 px-4 flex items-center gap-2">
<div className="h-2 w-2 bg-linear-to-br from-purple-400 to-purple-500 rounded-full"></div> <div className="h-2 w-2 bg-linear-to-br from-indigo-400 to-indigo-500 rounded-full"></div>
<span className="font-satoshi-bold text-md">0</span> <span className="font-satoshi-bold text-md">{userXp}</span>
</div> </div>
</header> </header>
<section> <section>
<Card <Card
className="relative bg-linear-to-br from-purple-500 to-purple-600 rounded-4xl className="relative bg-linear-to-br from-indigo-500 to-indigo-600 rounded-4xl
flex-row" flex-row"
> >
<div className="space-y-4"> <div className="space-y-4">
@ -61,7 +66,10 @@ export const Practice = () => {
<section className="flex flex-col gap-6"> <section className="flex flex-col gap-6">
<h1 className="font-satoshi-black text-2xl">Practice your way</h1> <h1 className="font-satoshi-black text-2xl">Practice your way</h1>
<div className="md:grid md:grid-cols-2 md:gap-6 space-y-6 md:space-y-0"> <div className="md:grid md:grid-cols-2 md:gap-6 space-y-6 md:space-y-0">
<Card className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1"> <Card
onClick={() => navigate("/student/practice/targeted-practice")}
className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1"
>
<CardHeader className="space-y-3"> <CardHeader className="space-y-3">
<div className="w-fit bg-linear-to-br from-red-400 to-red-500 p-3 rounded-2xl"> <div className="w-fit bg-linear-to-br from-red-400 to-red-500 p-3 rounded-2xl">
<Target size={20} color="white" /> <Target size={20} color="white" />
@ -81,7 +89,10 @@ export const Practice = () => {
</CardAction> </CardAction>
</CardHeader> </CardHeader>
</Card> </Card>
<Card className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1"> <Card
onClick={() => navigate("/student/practice/drills")}
className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1"
>
<CardHeader className="space-y-3"> <CardHeader className="space-y-3">
<div className="w-fit bg-linear-to-br from-cyan-400 to-cyan-500 p-3 rounded-2xl"> <div className="w-fit bg-linear-to-br from-cyan-400 to-cyan-500 p-3 rounded-2xl">
<Zap size={20} color="white" /> <Zap size={20} color="white" />
@ -99,7 +110,10 @@ export const Practice = () => {
</CardAction> </CardAction>
</CardHeader> </CardHeader>
</Card> </Card>
<Card className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1"> <Card
onClick={() => navigate("/student/practice/hard-test-modules")}
className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1"
>
<CardHeader className="space-y-3"> <CardHeader className="space-y-3">
<div className="w-fit bg-linear-to-br from-lime-400 to-lime-500 p-3 rounded-2xl"> <div className="w-fit bg-linear-to-br from-lime-400 to-lime-500 p-3 rounded-2xl">
<Trophy size={20} color="white" /> <Trophy size={20} color="white" />
@ -121,6 +135,6 @@ export const Practice = () => {
</Card> </Card>
</div> </div>
</section> </section>
</main> </div>
); );
}; };

View File

@ -12,7 +12,7 @@ export const Profile = () => {
}; };
return ( return (
<main className="min-h-screen space-y-6 max-w-7xl mx-auto px-8 sm:px-6 md:px-26 lg:px-8 py-8"> <main className="min-h-screen space-y-6 mx-auto p-8">
<h1 className="text-lg font-satoshi-bold text-center">Profile</h1> <h1 className="text-lg font-satoshi-bold text-center">Profile</h1>
<section> <section>
<h3 className="text-2xl font-satoshi-bold">{user?.name}</h3> <h3 className="text-2xl font-satoshi-bold">{user?.name}</h3>
@ -63,7 +63,7 @@ export const Profile = () => {
</section> </section>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="w-full border rounded-4xl bg-purple-500 py-4 px-4 flex justify-center items-center active:bg-purple-600 font-satoshi-medium text-white" className="w-full border rounded-4xl bg-linear-to-br from-indigo-400 to-indigo-600 py-4 px-4 flex justify-center items-center active:bg-purple-600 font-satoshi-medium text-white"
> >
Sign Out Sign Out
</button> </button>

View File

@ -3,7 +3,7 @@ import firstTrophy from "../../assets/icons/first_trophy.png";
import secondTrophy from "../../assets/icons/second_trophy.png"; import secondTrophy from "../../assets/icons/second_trophy.png";
import thirdTrophy from "../../assets/icons/third_trophy.png"; import thirdTrophy from "../../assets/icons/third_trophy.png";
import { useState } from "react"; import { useEffect, useState } from "react";
// import { // import {
// Card, // Card,
// CardHeader, // CardHeader,
@ -27,34 +27,89 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../../components/ui/dropdown-menu"; } from "../../components/ui/dropdown-menu";
import { formatTimeFilter, getRandomColor } from "../../lib/utils"; import { formatTimeFilter, getRandomColor } from "../../lib/utils";
import { Avatar, AvatarFallback } from "../../components/ui/avatar"; import {
import { Zap } from "lucide-react"; Avatar,
AvatarFallback,
AvatarImage,
} from "../../components/ui/avatar";
import { Flame, LucideBadgeQuestionMark, Zap } from "lucide-react";
import type { Leaderboard } from "../../types/leaderboard";
import { api } from "../../utils/api";
import { Card, CardContent } from "../../components/ui/card";
import { LeaderboardRowSkeleton } from "../../components/LeaderboardSkeleton";
import { useExamConfigStore } from "../../stores/useExamConfigStore";
export const Rewards = () => { export const Rewards = () => {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const [time, setTime] = useState("bottom"); const [time, setTime] = useState("bottom");
const [activeTab, setActiveTab] = useState("xp");
const [leaderboard, setLeaderboard] = useState<Leaderboard>();
const [loading, setLoading] = useState<boolean>(false);
const { setUserXp } = useExamConfigStore();
useEffect(() => {
const fetchLeaderboard = async () => {
if (!user) return;
try {
setLoading(true);
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const parsed = JSON.parse(authStorage) as {
state?: { token?: string };
};
const token = parsed.state?.token;
if (!token) return;
const response = await api.fetchLeaderboard(token);
setLeaderboard(response);
setUserXp(response.user_rank.total_xp);
setLoading(false);
} catch (error) {
setLoading(false);
console.error("Error fetching leaderboard: " + error);
}
};
fetchLeaderboard();
}, [user]);
const leaderboard = [
{ id: 1, name: "Alice", xp: 587 },
{ id: 2, name: "Bob", xp: 560 },
{ id: 3, name: "Charlie", xp: 540 },
{ id: 4, name: "David", xp: 510 },
{ id: 5, name: "Emma", xp: 495 },
];
const trophies = [firstTrophy, secondTrophy, thirdTrophy]; const trophies = [firstTrophy, secondTrophy, thirdTrophy];
const isTopThree = (leaderboard?.user_rank?.rank ?? Infinity) < 3;
return ( return (
<main className="flex flex-col gap-8 items-start min-h-screen mx-auto px-8 sm:px-6 lg:px-8 py-8"> <div className="relative flex flex-col gap-8 items-start mx-auto sm:px-6 lg:px-8 py-8">
<header className="flex flex-col items-center h-fit w-full gap-3"> <header className="flex flex-col items-center h-fit w-full gap-3">
<h1 className="font-satoshi-black text-3xl">Leaderboards</h1> <h1 className="font-satoshi-black text-3xl">Leaderboards</h1>
{loading ? (
<div className="animate-pulse">
<div className="h-4 w-60 bg-gray-200 rounded" />
</div>
) : (
<p className="font-satoshi-medium text-md text-gray-500"> <p className="font-satoshi-medium text-md text-gray-500">
Complete lessons to rise to the top.{" "} Don't stop now! You're{" "}
<span className="underline">Start a lesson.</span> <span className="text-indigo-400">
#{leaderboard?.user_rank.rank}
</span>{" "}
in XP.
</p> </p>
)}
</header> </header>
<section className="w-full"> <section className="w-full px-7">
<Tabs defaultValue="xp" className="space-y-6"> <Tabs
<TabsList className="bg-transparent p-0 w-full justify-between "> value={activeTab}
onValueChange={setActiveTab}
defaultValue="xp"
className="space-y-6 h-[calc(100vh-250px)] flex flex-col"
>
<TabsList className="bg-transparent p-0 w-full justify-between shrink-0">
<TabsTrigger <TabsTrigger
value="xp" value="xp"
className="font-satoshi-bold px-4 tracking-wide text-md rounded-none border-b-2 data-[state=active]:font-satoshi-medium data-[state=active]:border-b-indigo-800 data-[state=active]:text-indigo-800" className="font-satoshi-bold px-4 tracking-wide text-md rounded-none border-b-2 data-[state=active]:font-satoshi-medium data-[state=active]:border-b-indigo-800 data-[state=active]:text-indigo-800"
@ -100,13 +155,23 @@ export const Rewards = () => {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</TabsList> </TabsList>
<TabsContent value="xp" className="space-y-6"> <TabsContent
{leaderboard.map((user, index) => { value="xp"
className="flex-1 overflow-y-auto space-y-6 pb-8"
>
{loading ? (
<div className="space-y-6">
{Array.from({ length: 6 }).map((_, i) => (
<LeaderboardRowSkeleton key={i} />
))}
</div>
) : (
leaderboard?.top_users.map((user, index) => {
const isTopThree = index < 3; const isTopThree = index < 3;
return ( return (
<div <div
key={user.id} key={user.user_id}
className="flex justify-between items-center" className="flex justify-between items-center"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -123,6 +188,7 @@ export const Rewards = () => {
)} )}
<Avatar className={`p-6 ${getRandomColor()}`}> <Avatar className={`p-6 ${getRandomColor()}`}>
<AvatarImage src={user.avatar_url} />
<AvatarFallback className="text-white font-satoshi-bold"> <AvatarFallback className="text-white font-satoshi-bold">
{user.name.slice(0, 1).toUpperCase()} {user.name.slice(0, 1).toUpperCase()}
</AvatarFallback> </AvatarFallback>
@ -134,15 +200,19 @@ export const Rewards = () => {
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<p className="font-satoshi-medium">{user.xp}</p> <p className="font-satoshi-medium">{user.total_xp}</p>
<Zap size={20} color="darkgreen" /> <Zap size={20} className="text-lime-500 fill-lime-200" />
</div> </div>
</div> </div>
); );
})} })
)}
</TabsContent> </TabsContent>
<TabsContent value="questions" className="space-y-6"> <TabsContent
{leaderboard.map((user, index) => { value="questions"
className="flex-1 overflow-y-auto space-y-6"
>
{/* {leaderboard.map((user, index) => {
const isTopThree = index < 3; const isTopThree = index < 3;
return ( return (
@ -180,10 +250,13 @@ export const Rewards = () => {
</div> </div>
</div> </div>
); );
})} })} */}
</TabsContent> </TabsContent>
<TabsContent value="streak" className="space-y-6"> <TabsContent
{leaderboard.map((user, index) => { value="streak"
className="flex-1 overflow-y-auto space-y-6"
>
{/* {leaderboard.map((user, index) => {
const isTopThree = index < 3; const isTopThree = index < 3;
return ( return (
@ -221,10 +294,79 @@ export const Rewards = () => {
</div> </div>
</div> </div>
); );
})} })} */}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</section> </section>
</main> <Card className="absolute mx-auto w-full left-0 md:-bottom-12 bottom-0 bg-linear-to-br from-indigo-500 to-indigo-600 rounded-full py-4">
<CardContent className="flex justify-between items-center">
{loading ? (
<div className="flex justify-between items-center animate-pulse w-full">
<div className="flex items-center gap-3">
{/* Rank / Trophy */}
<div className="w-12 h-12 rounded-full bg-gray-200" />
{/* Avatar */}
<div className="w-12 h-12 rounded-full bg-gray-300" />
{/* Name */}
<div className="h-4 w-32 bg-gray-200 rounded" />
</div>
{/* XP */}
<div className="flex items-center gap-2">
<div className="h-4 w-10 bg-gray-200 rounded" />
<div className="w-5 h-5 rounded bg-gray-200" />
</div>
</div>
) : (
<>
<div className="flex items-center gap-3">
{isTopThree ? (
<img
src={
trophies[(leaderboard?.user_rank?.rank ?? Infinity) - 1]
}
alt={`trophy_${leaderboard?.user_rank?.rank ?? Infinity}`}
className="w-12 h-12"
/>
) : (
<span className="w-12 text-center font-satoshi-bold text-white">
{(leaderboard?.user_rank?.rank ?? Infinity) - 1}
</span>
)}
<Avatar className={`p-6 bg-white`}>
<AvatarImage src={leaderboard?.user_rank.avatar_url} />
<AvatarFallback className=" font-satoshi-bold">
{leaderboard?.user_rank.name.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<p className="font-satoshi-bold text-white">
{leaderboard?.user_rank.name}
</p>
</div>
<div className="flex items-center gap-1">
<p className="font-satoshi-medium text-white">
{activeTab === "xp"
? leaderboard?.user_rank.total_xp
: activeTab === "questions"
? "23"
: "5"}
</p>
{activeTab === "xp" ? (
<Zap size={20} color="white" />
) : activeTab === "questions" ? (
<LucideBadgeQuestionMark size={20} color="white" />
) : (
<Flame size={20} color="white" />
)}
</div>
</>
)}
</CardContent>
</Card>
</div>
); );
}; };

View File

@ -1,5 +1,7 @@
import { Outlet, NavLink } from "react-router-dom"; import { Outlet, NavLink } from "react-router-dom";
import { Home, BookOpen, Award, User, Video } from "lucide-react"; import { Home, BookOpen, Award, User, Video } from "lucide-react";
import { SidebarProvider, SidebarTrigger } from "../../components/ui/sidebar";
import { AppSidebar } from "../../components/AppSidebar";
export function StudentLayout() { export function StudentLayout() {
const navItems = [ const navItems = [
@ -11,14 +13,20 @@ export function StudentLayout() {
]; ];
return ( return (
<div className="flex flex-col min-h-screen bg-gray-50"> <SidebarProvider>
{/* Main Content */} <div className="flex min-h-screen w-full overflow-x-hidden">
<main className="flex-1 pb-20 overflow-y-auto"> {/* Desktop Sidebar */}
<AppSidebar />
<div className="flex flex-col flex-1 min-w-0">
<SidebarTrigger className="hidden md:block" />
<main className="flex-1 pb-24 md:pb-0">
<Outlet /> <Outlet />
</main> </main>
</div>
{/* Bottom Tab Navigation */} {/* Mobile bottom nav */}
<nav className="fixed rounded-t-4xl pt-2 bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-4xl z-20"> <nav className="fixed bottom-0 left-0 right-0 rounded-t-4xl pt-2 bg-white border-t border-gray-200 shadow-4xl z-20 md:hidden">
<div className="max-w-7xl mx-auto px-2"> <div className="max-w-7xl mx-auto px-2">
<div className="flex justify-around items-center"> <div className="flex justify-around items-center">
{navItems.map((item) => ( {navItems.map((item) => (
@ -28,7 +36,7 @@ export function StudentLayout() {
className={({ isActive }) => className={({ isActive }) =>
`flex flex-col items-center justify-center py-3 px-4 flex-1 transition-all duration-200 font-satoshi tracking-wide ${ `flex flex-col items-center justify-center py-3 px-4 flex-1 transition-all duration-200 font-satoshi tracking-wide ${
isActive isActive
? "text-purple-600" ? "text-indigo-600"
: "text-gray-500 hover:text-gray-700" : "text-gray-500 hover:text-gray-700"
}` }`
} }
@ -57,5 +65,6 @@ export function StudentLayout() {
</div> </div>
</nav> </nav>
</div> </div>
</SidebarProvider>
); );
} }

View File

@ -0,0 +1,212 @@
import { useEffect, useState } from "react";
import { useAuthStore } from "../../../stores/authStore";
import type { Topic } from "../../../types/topic";
import { api } from "../../../utils/api";
import { ChoiceCard } from "../../../components/ChoiceCard";
import { AnimatePresence, motion } from "framer-motion";
import { slideVariants } from "../../../lib/utils";
import { Loader2 } from "lucide-react";
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
import { useNavigate } from "react-router-dom";
type Step = "topic" | "review";
export const Drills = () => {
const user = useAuthStore((state) => state.user);
const navigate = useNavigate();
const [direction, setDirection] = useState<1 | -1>(1);
const [topics, setTopics] = useState<Topic[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [selectedTopics, setSelectedTopics] = useState<Topic[]>([]);
const [search, setSearch] = useState("");
const [step, setStep] = useState<Step>("topic");
const { storeTopics, setMode, setQuestionCount } = useExamConfigStore();
const toggleTopic = (topic: Topic) => {
setSelectedTopics((prev) => {
const exists = prev.some((t) => t.id === topic.id);
if (exists) {
return prev.filter((t) => t.id !== topic.id);
}
return [...prev, topic];
});
};
function handleStartDrill() {
if (!user || !topics) return;
navigate(`/student/practice/${topics[0].id}/test`, { replace: true });
}
useEffect(() => {
const fetchAllTopics = async () => {
if (!user) return;
try {
setLoading(true);
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const parsed = JSON.parse(authStorage) as {
state?: { token?: string };
};
const token = parsed.state?.token;
if (!token) return;
const response = await api.fetchAllTopics(token);
setTopics(response);
setLoading(false);
} catch (error) {
console.error("Failed to load topics. Reason: " + error);
}
};
fetchAllTopics();
}, [user]);
return (
<main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
<header className="space-y-2">
<h1 className="font-satoshi-bold text-3xl">Drills</h1>
<p className="font-satoshi text-md text-gray-500">
Train your speed and accuracy with our drill-based testing system.
</p>
</header>
<section>
<div className="relative overflow-hidden">
<AnimatePresence mode="wait">
{step === "topic" && (
<motion.div
custom={direction}
key="topic"
variants={slideVariants}
initial="initial"
animate="animate"
exit="exit"
className="space-y-4"
>
<h2 className="text-xl font-satoshi-bold">Choose a topic</h2>
<input
placeholder="Search topics..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border px-4 py-2"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{loading ? (
<>
<div>
<Loader2
size={30}
color="purple"
className="animate-spin"
/>
</div>
</>
) : (
topics
.filter((t) =>
t.name.toLowerCase().includes(search.toLowerCase()),
)
.map((t) => (
<ChoiceCard
key={t.id}
label={t.name}
subLabel={t.parent_name}
section={t.section}
selected={selectedTopics.some((st) => st.id === t.id)}
onClick={() => toggleTopic(t)}
/>
))
)}
</div>
<button
disabled={selectedTopics.length === 0}
onClick={() => {
// ✅ STORE
storeTopics(selectedTopics.map((t) => t.id)); // ✅ STORE
setMode("DRILL"); // ✅ STORE
setQuestionCount(7); // ✅ STORE
setDirection(1);
setStep("review");
}}
className={`rounded-2xl py-3 px-6 font-satoshi-bold transition
${
selectedTopics.length === 0
? "bg-gray-300 text-gray-500 cursor-not-allowed"
: "bg-linear-to-br from-purple-500 to-purple-600 text-white"
}`}
>
Next
</button>
</motion.div>
)}
{step === "review" && (
<motion.div
custom={direction}
key="review"
variants={slideVariants}
initial="initial"
animate="animate"
exit="exit"
className="space-y-6"
>
<h2 className="text-xl font-satoshi-bold">
Review your choices
</h2>
<div className="rounded-2xl border p-4 space-y-2 font-satoshi">
<p>
<strong>Topics:</strong>{" "}
{selectedTopics.map((t) => t.name).join(", ")}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<button
disabled={step === "topic"}
onClick={() => {
const order: Step[] = ["topic", "review"];
setDirection(-1);
setStep(order[order.indexOf(step) - 1]);
}}
className={`absolute bottom-24 left-10 rounded-2xl py-3 px-6 font-satoshi-bold transition
${
step === "topic"
? "opacity-0 pointer-events-none"
: "bg-linear-to-br from-slate-500 to-slate-600 text-white"
}`}
>
Back
</button>
<button
disabled={step !== "review"}
className={`absolute bottom-24 right-10 rounded-2xl py-3 px-6 font-satoshi-bold transition
${
step !== "review"
? "opacity-0 pointer-events-none"
: "bg-linear-to-br from-purple-500 to-purple-600 text-white"
}`}
onClick={() => {
handleStartDrill();
}}
>
Start Test
</button>
</section>
</main>
);
};

View File

@ -0,0 +1,119 @@
import {
DecimalsArrowRight,
Languages,
Percent,
Pilcrow,
Superscript,
WholeWord,
} from "lucide-react";
import { Card, CardContent } from "../../../components/ui/card";
import { useState } from "react";
import { useAuthStore } from "../../../stores/authStore";
import { useNavigate } from "react-router-dom";
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
type Module = "EBRW" | "MATH" | null;
export const HardTestModules = () => {
const user = useAuthStore((state) => state.user);
const navigate = useNavigate();
const [selected, setSelected] = useState<Module>(null);
const { setMode, storeDuration, setSection } = useExamConfigStore();
function handleStartModule() {
if (!user) return;
(setMode("MODULE"), storeDuration(7), setSection(selected));
navigate(`/student/practice/${selected}/test`, { replace: true });
}
return (
<main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
<header className="space-y-2">
<h1 className="font-satoshi-bold text-3xl">Hard Test Modules</h1>
<p className="font-satoshi text-md text-gray-500">
Tackle hard practice test modules by selecting a section.
</p>
</header>
<section className="space-y-6">
<Card
onClick={() =>
setSelected((prev) => (prev === "EBRW" ? null : "EBRW"))
}
className={`relative cursor-pointer overflow-hidden transition
${
selected === "EBRW"
? "ring-2 ring-blue-500 scale-[1.02]"
: "hover:scale-[1.01]"
}
bg-linear-to-br from-blue-400 to-blue-600
`}
>
<CardContent className="z-10 flex items-center justify-center py-16 ">
<h1 className="font-satoshi-bold text-2xl text-blue-50">
Reading & Writing
</h1>
</CardContent>
<Languages
size={250}
className="absolute -top-5 -right-10 -rotate-23 text-white opacity-30"
/>
<WholeWord
size={150}
className="absolute -top-10 -left-3 rotate-23 text-white opacity-30"
/>
<Pilcrow
size={150}
className="absolute -bottom-12 left-8 -rotate-23 text-white opacity-30"
/>
</Card>
<Card
onClick={() =>
setSelected((prev) => (prev === "MATH" ? null : "MATH"))
}
className={`relative cursor-pointer overflow-hidden transition
${
selected === "MATH"
? "ring-2 ring-rose-500 scale-[1.02]"
: "hover:scale-[1.01]"
}
bg-linear-to-br from-rose-400 to-rose-600
`}
>
<CardContent className="z-10 flex items-center justify-center py-16 ">
<h1 className="font-satoshi-bold text-2xl text-blue-50">
Mathematics
</h1>
</CardContent>
<DecimalsArrowRight
size={250}
className="absolute -top-5 -right-10 -rotate-23 text-white opacity-30"
/>
<Superscript
size={150}
className="absolute -top-10 -left-3 rotate-23 text-white opacity-30"
/>
<Percent
size={120}
className="absolute -bottom-5 left-8 -rotate-10 text-white opacity-30"
/>
</Card>
</section>
{selected && (
<div className=" bottom-6 left-0 right-0 flex justify-center z-50">
<button
onClick={() => {
handleStartModule();
}}
className="rounded-2xl px-10 py-4 font-satoshi-bold text-lg
bg-linear-to-br from-purple-500 to-purple-600 text-white
shadow-xl animate-in slide-in-from-bottom-4"
>
Start Test
</button>
</div>
)}
</main>
);
};

View File

@ -1,16 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Outlet, replace, useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { api } from "../../../utils/api"; import { api } from "../../../utils/api";
import { useAuthStore } from "../../../stores/authStore"; import { useAuthStore } from "../../../stores/authStore";
import type { PracticeSheet } from "../../../types/sheet"; import type { PracticeSheet } from "../../../types/sheet";
import { import { CircleQuestionMark, Clock, Layers, Loader, Tag } from "lucide-react";
CircleQuestionMark,
Clock,
Layers,
Loader,
Loader2,
Tag,
} from "lucide-react";
import { import {
Carousel, Carousel,
CarouselContent, CarouselContent,
@ -19,8 +12,11 @@ import {
} from "../../../components/ui/carousel"; } from "../../../components/ui/carousel";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
export const Pretest = () => { export const Pretest = () => {
const { setSheetId, setMode, storeDuration, setQuestionCount } =
useExamConfigStore();
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const { sheetId } = useParams<{ sheetId: string }>(); const { sheetId } = useParams<{ sheetId: string }>();
const [carouselApi, setCarouselApi] = useState<CarouselApi>(); const [carouselApi, setCarouselApi] = useState<CarouselApi>();
@ -37,6 +33,12 @@ export const Pretest = () => {
console.error("Sheet ID is required to start the test."); console.error("Sheet ID is required to start the test.");
return; return;
} }
setSheetId(sheetId);
setMode("SIMULATION");
storeDuration(practiceSheet?.time_limit ?? 0);
setQuestionCount(2);
navigate(`/student/practice/${sheetId}/test`, { replace: true }); navigate(`/student/practice/${sheetId}/test`, { replace: true });
} }
@ -73,7 +75,7 @@ export const Pretest = () => {
}, [carouselApi]); }, [carouselApi]);
return ( return (
<main className="max-w-full mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-6"> <div className="p-8 space-y-6">
<header className="space-y-2"> <header className="space-y-2">
<h1 className="text-4xl font-satoshi-bold">{practiceSheet?.title}</h1> <h1 className="text-4xl font-satoshi-bold">{practiceSheet?.title}</h1>
<p className="text-lg font-satoshi text-gray-700"> <p className="text-lg font-satoshi text-gray-700">
@ -117,8 +119,8 @@ export const Pretest = () => {
</div> </div>
</section> </section>
)} )}
<Carousel setApi={setCarouselApi}> <Carousel className="" setApi={setCarouselApi}>
<CarouselContent className=""> <CarouselContent>
{practiceSheet ? ( {practiceSheet ? (
practiceSheet.modules.length > 0 ? ( practiceSheet.modules.length > 0 ? (
practiceSheet.modules.map((module, index) => ( practiceSheet.modules.map((module, index) => (
@ -130,7 +132,7 @@ export const Pretest = () => {
<p className="text-lg font-satoshi text-gray-700"> <p className="text-lg font-satoshi text-gray-700">
{module.title} {module.title}
</p> </p>
<section className="flex justify-between"> <section className="grid grid-cols-3 gap-6 sm:grid-cols-3">
<div className="flex flex-col justify-center items-center gap-4"> <div className="flex flex-col justify-center items-center gap-4">
<div className="w-fit bg-cyan-100 p-2 rounded-full"> <div className="w-fit bg-cyan-100 p-2 rounded-full">
<Clock size={30} color="oklch(60.9% 0.126 221.723)" /> <Clock size={30} color="oklch(60.9% 0.126 221.723)" />
@ -199,7 +201,7 @@ export const Pretest = () => {
<div <div
key={index} key={index}
className={`w-2 h-2 mx-1 rounded-full ${ className={`w-2 h-2 mx-1 rounded-full ${
index + 1 === current ? "bg-purple-500" : "bg-gray-300" index + 1 === current ? "bg-indigo-500" : "bg-gray-300"
}`} }`}
></div> ></div>
))} ))}
@ -214,7 +216,7 @@ export const Pretest = () => {
<Button <Button
onClick={() => handleStartTest(practiceSheet?.id!)} onClick={() => handleStartTest(practiceSheet?.id!)}
variant="outline" variant="outline"
className="font-satoshi rounded-3xl w-full text-lg py-8 bg-linear-to-br from-purple-500 to-purple-600 text-white active:bg-linear-to-br active:from-purple-600 active:to-purple-700 active:translate-y-1" className="font-satoshi rounded-3xl w-full text-lg py-8 bg-linear-to-br from-indigo-500 to-indigo-600 text-white active:bg-linear-to-br active:from-indigo-600 active:to-indigo-700 active:translate-y-1"
disabled={!practiceSheet} disabled={!practiceSheet}
> >
{practiceSheet ? ( {practiceSheet ? (
@ -225,6 +227,6 @@ export const Pretest = () => {
</div> </div>
)} )}
</Button> </Button>
</main> </div>
); );
}; };

View File

@ -1,12 +1,251 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "../../../components/ui/button"; import { useResults } from "../../../stores/useResults";
import { LucideArrowLeft } from "lucide-react";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { CircularLevelProgress } from "../../../components/CircularLevelProgress";
import { useEffect, useState } from "react";
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
const XPGainedCard = ({
results,
}: {
results?: {
xp_gained: number;
total_xp: number;
current_level_start: number;
next_level_threshold: number;
current_level: number;
};
}) => {
const [displayXP, setDisplayXP] = useState(0);
useEffect(() => {
if (!results?.xp_gained) return;
let startTime: number | null = null;
const duration = 800;
const animate = (time: number) => {
if (!startTime) startTime = time;
const t = Math.min((time - startTime) / duration, 1);
setDisplayXP(Math.floor(t * results.xp_gained));
if (t < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [results?.xp_gained]);
export const Results = () => {
const navigate = useNavigate();
return ( return (
<div className="min-h-screen flex items-center justify-center text-2xl font-satoshi-bold"> <Card>
Your results go here <CardHeader>
<Button onClick={() => navigate("/student/home")}>Go to home</Button> <CardTitle>XP</CardTitle>
</div> <CardDescription>How much did you improve?</CardDescription>
<CardAction>
<p className="font-satoshi-medium">+{displayXP} XP</p>
</CardAction>
</CardHeader>
</Card>
);
};
// ─── Targeted static results ──────────────────────────────────────────────────
const TARGETED_XP = 15;
const TARGETED_SCORE = 15;
const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
const { userXp, setUserXp } = useExamConfigStore();
// previousXP is whatever the user had before; we add 15 on top
const previousXP = userXp ?? 0;
const gainedXP = TARGETED_XP;
const totalXP = previousXP;
// Sync updated XP back into the store
useEffect(() => {
setUserXp(totalXP);
}, []);
// Simple level bounds — 0100 per level so progress is visible
// Adjust these to match your real level thresholds if needed
const levelMinXP = Math.floor(previousXP / 100) * 100;
const levelMaxXP = levelMinXP + 100;
const currentLevel = Math.floor(previousXP / 100) + 1;
const [displayXP, setDisplayXP] = useState(0);
useEffect(() => {
let startTime: number | null = null;
const duration = 800;
const animate = (time: number) => {
if (!startTime) startTime = time;
const t = Math.min((time - startTime) / duration, 1);
setDisplayXP(Math.floor(t * gainedXP));
if (t < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, []);
return (
<main className="min-h-screen bg-gray-50 space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-10">
<header className="flex gap-4">
<button
onClick={onFinish}
className="p-2 rounded-full border border-purple-400 bg-linear-to-br from-purple-400 to-purple-500"
>
<LucideArrowLeft size={20} color="white" />
</button>
<h1 className="text-3xl font-satoshi-bold">Results</h1>
</header>
{/* Targeted mode badge */}
<div className="flex items-center gap-2 bg-purple-50 border border-purple-200 rounded-2xl px-4 py-3">
<span className="text-xl">🎯</span>
<p className="font-satoshi text-purple-700 text-sm">
<strong>Targeted Mode Complete!</strong> You answered all questions
correctly.
</p>
</div>
<section className="w-full flex items-center justify-center">
<CircularLevelProgress
previousXP={previousXP}
gainedXP={gainedXP}
levelMinXP={levelMinXP}
levelMaxXP={levelMaxXP}
level={currentLevel}
/>
</section>
<Card>
<CardHeader>
<CardTitle>XP</CardTitle>
<CardDescription>How much did you improve?</CardDescription>
<CardAction>
<p className="font-satoshi-medium">+{displayXP} XP</p>
</CardAction>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle>Score</CardTitle>
<CardDescription>Total score you achieved.</CardDescription>
<CardAction>
<p className="font-satoshi-medium">{TARGETED_SCORE}</p>
</CardAction>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle>Keep it up! 🚀</CardTitle>
<CardDescription>
Great work getting every question right. Keep practicing to level up
faster!
</CardDescription>
</CardHeader>
</Card>
<button
onClick={onFinish}
className="w-full font-satoshi rounded-3xl text-lg py-4 bg-linear-to-br from-purple-500 to-purple-600 text-white"
>
Done
</button>
</main>
);
};
// ─── Main Results ─────────────────────────────────────────────────────────────
export const Results = () => {
const navigate = useNavigate();
const results = useResults((s) => s.results);
const clearResults = useResults((s) => s.clearResults);
const { setUserXp, payload } = useExamConfigStore();
const isTargeted = payload?.mode === "TARGETED";
useEffect(() => {
if (results) setUserXp(results?.total_xp);
}, [results]);
function handleFinishExam() {
useExamConfigStore.getState().clearPayload();
clearResults();
navigate(`/student/home`);
}
// ── Targeted mode: show static screen ──────────────────────────────────────
if (isTargeted) {
return <TargetedResults onFinish={handleFinishExam} />;
}
// ── Standard mode ──────────────────────────────────────────────────────────
const previousXP = results ? results.total_xp - results.xp_gained : 0;
return (
<main className="min-h-screen bg-gray-50 space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-10">
<header className="flex gap-4">
<button
onClick={() => handleFinishExam()}
className="p-2 rounded-full border border-purple-400 bg-linear-to-br from-purple-400 to-purple-500"
>
<LucideArrowLeft size={20} color="white" />
</button>
<h1 className="text-3xl font-satoshi-bold">Results</h1>
</header>
<section className="w-full flex items-center justify-center">
{results && (
<CircularLevelProgress
previousXP={previousXP}
gainedXP={results.xp_gained}
levelMinXP={results.current_level_start}
levelMaxXP={results.next_level_threshold}
level={results.current_level}
/>
)}
</section>
<XPGainedCard results={results} />
<Card>
<CardHeader>
<CardTitle>Score</CardTitle>
<CardDescription>Total score you achieved.</CardDescription>
<CardAction>
<p className="font-satoshi-medium">{results?.score}</p>
</CardAction>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle>Accuracy</CardTitle>
<CardDescription>How many did you answer correct?</CardDescription>
<CardAction>
<p className="font-satoshi-medium">
{results && results.total_questions > 0
? `${Math.round(
(results.correct_count / results.total_questions) * 100,
)}%`
: "—"}
</p>
</CardAction>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle>How do you improve?</CardTitle>
<CardDescription>
Your score is good, but you can do better!
</CardDescription>
</CardHeader>
</Card>
</main>
); );
}; };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,262 @@
import { useEffect, useState } from "react";
import { api } from "../../../utils/api";
import { type Topic } from "../../../types/topic";
import { useAuthStore } from "../../../stores/authStore";
import { Loader2 } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { slideVariants } from "../../../lib/utils";
import { ChoiceCard } from "../../../components/ChoiceCard";
import { useAuthToken } from "../../../hooks/useAuthToken";
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
import { useNavigate } from "react-router-dom";
type Step = "topic" | "difficulty" | "duration" | "review";
export const TargetedPractice = () => {
const navigate = useNavigate();
const {
storeTopics,
storeDuration,
setDifficulty: storeDifficulty,
setMode,
setQuestionCount,
} = useExamConfigStore();
const user = useAuthStore((state) => state.user);
const token = useAuthToken();
const [direction, setDirection] = useState<1 | -1>(1);
const [step, setStep] = useState<Step>("topic");
const [selectedTopics, setSelectedTopics] = useState<Topic[]>([]);
const [difficulty, setDifficulty] = useState<
"EASY" | "MEDIUM" | "HARD" | null
>(null);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState<boolean>(false);
const [topics, setTopics] = useState<Topic[]>([]);
const difficulties = ["EASY", "MEDIUM", "HARD"] as const;
const toggleTopic = (topic: Topic) => {
setSelectedTopics((prev) => {
const exists = prev.some((t) => t.id === topic.id);
if (exists) {
return prev.filter((t) => t.id !== topic.id);
}
return [...prev, topic];
});
};
async function handleStartTargetedPractice() {
if (!user || !token || !topics || !difficulty) return;
storeDuration(10);
navigate(`/student/practice/${topics[0].id}/test`, { replace: true });
}
useEffect(() => {
const fetchAllTopics = async () => {
if (!user) return;
try {
setLoading(true);
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const parsed = JSON.parse(authStorage) as {
state?: { token?: string };
};
const token = parsed.state?.token;
if (!token) return;
const response = await api.fetchAllTopics(token);
setTopics(response);
setLoading(false);
} catch (error) {
console.error("Failed to load topics. Reason: " + error);
}
};
fetchAllTopics();
}, [user]);
return (
<main className="relative min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
<header className="space-y-2">
<h1 className="font-satoshi-bold text-3xl">Targeted Practice</h1>
<p className="font-satoshi text-md text-gray-500">
Focus on what really matters. Define your own test and get to
practicing what you really need.
</p>
</header>
<div className="relative overflow-hidden">
<AnimatePresence mode="wait">
{step === "topic" && (
<motion.div
custom={direction}
key="topic"
variants={slideVariants}
initial="initial"
animate="animate"
exit="exit"
className="space-y-4"
>
<h2 className="text-xl font-satoshi-bold">Choose a topic</h2>
<input
placeholder="Search topics..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border px-4 py-2"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{loading ? (
<>
<div>
<Loader2
size={30}
color="indigo"
className="animate-spin"
/>
</div>
</>
) : (
topics
.filter((t) =>
t.name.toLowerCase().includes(search.toLowerCase()),
)
.map((t) => (
<ChoiceCard
key={t.id}
label={t.name}
subLabel={t.parent_name}
section={t.section}
selected={selectedTopics.some((st) => st.id === t.id)}
onClick={() => toggleTopic(t)}
/>
))
)}
</div>
<button
disabled={selectedTopics.length === 0}
onClick={() => {
// ✅ STORE
storeTopics(selectedTopics.map((t) => t.id)); // ✅ STORE
setMode("TARGETED"); // ✅ STORE
setQuestionCount(7); // ✅ STORE
setDirection(1);
setStep("difficulty");
}}
className={`rounded-2xl py-3 px-6 font-satoshi-bold transition
${
selectedTopics.length === 0
? "bg-gray-300 text-gray-500 cursor-not-allowed"
: "bg-linear-to-br from-indigo-500 to-indigo-600 text-white"
}`}
>
Next
</button>
</motion.div>
)}
{step === "difficulty" && (
<motion.div
key="difficulty"
custom={direction}
variants={slideVariants}
initial="initial"
animate="animate"
exit="exit"
className="space-y-4"
>
<h2 className="text-xl font-satoshi-bold">Select difficulty</h2>
<div className="grid grid-cols-1 gap-3">
{difficulties.map((d) => (
<ChoiceCard
key={d}
label={d}
selected={difficulty === d}
onClick={() => {
setDifficulty(d); // local UI
storeDifficulty(d); // ✅ STORE
setDirection(1);
setStep("review");
}}
/>
))}
</div>
</motion.div>
)}
{step === "review" && (
<motion.div
custom={direction}
key="review"
variants={slideVariants}
initial="initial"
animate="animate"
exit="exit"
className="space-y-6"
>
<h2 className="text-xl font-satoshi-bold">Review your choices</h2>
<div className="rounded-2xl border p-4 space-y-2 font-satoshi">
<p>
<strong>Topics:</strong>{" "}
{selectedTopics.map((t) => t.name).join(", ")}
</p>
<p>
<strong>Difficulty:</strong> {difficulty}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<button
disabled={step === "topic"}
onClick={() => {
const order: Step[] = ["topic", "difficulty", "review"];
setDirection(-1);
setStep(order[order.indexOf(step) - 1]);
}}
className={`absolute bottom-24 left-10 rounded-2xl py-3 px-6 font-satoshi-bold transition
${
step === "topic"
? "opacity-0 pointer-events-none"
: "bg-linear-to-br from-slate-500 to-slate-600 text-white"
}`}
>
Back
</button>
<button
disabled={step !== "review"}
className={`absolute bottom-28 right-10 rounded-2xl py-3 px-6 font-satoshi-bold transition
${
step !== "review"
? "opacity-0 pointer-events-none"
: "bg-linear-to-br from-indigo-500 to-indigo-600 text-white"
}`}
onClick={() => {
handleStartTargetedPractice();
}}
>
Start Test
</button>
</main>
);
};

View File

@ -0,0 +1,94 @@
// stores/useExamConfigStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { StartExamPayload, ExamMode } from "../types/test";
interface ExamConfigState {
payload: StartExamPayload | null;
userXp: number;
setSheetId: (id: string) => void;
storeTopics: (ids: string[]) => void;
setDifficulty: (difficulty: StartExamPayload["difficulty"]) => void;
setQuestionCount: (count: number) => void;
storeDuration: (minutes: number) => void;
setMode: (mode: ExamMode) => void;
setSection: (section: string) => void;
setUserXp: (section: number) => void;
clearPayload: () => void;
}
export const useExamConfigStore = create<ExamConfigState>()(
persist(
(set, get) => ({
payload: null,
userXp: 0,
setSheetId: (sheet_id) =>
set({
payload: {
...(get().payload ?? {}),
sheet_id,
} as StartExamPayload,
}),
storeTopics: (topic_ids) =>
set({
payload: {
...(get().payload ?? {}),
topic_ids,
} as StartExamPayload,
}),
setSection: (section) =>
set({
payload: {
...(get().payload ?? {}),
section,
} as StartExamPayload,
}),
setUserXp: (userXp) =>
set({
userXp: userXp,
}),
setDifficulty: (difficulty) =>
set({
payload: {
...(get().payload ?? {}),
difficulty,
} as StartExamPayload,
}),
setQuestionCount: (question_count) =>
set({
payload: {
...(get().payload ?? {}),
question_count,
} as StartExamPayload,
}),
storeDuration: (time_limit_minutes) =>
set({
payload: {
...(get().payload ?? {}),
time_limit_minutes,
} as StartExamPayload,
}),
setMode: (mode) =>
set({
payload: {
...(get().payload ?? {}),
mode,
} as StartExamPayload,
}),
clearPayload: () => set({ payload: null }),
}),
{
name: "exam-config-storage",
},
),
);

16
src/stores/useResults.ts Normal file
View File

@ -0,0 +1,16 @@
import { create } from "zustand";
import type { Results } from "../types/test";
interface ResultsState {
results: Results | null;
setResults: (results: Results) => void;
clearResults: () => void;
}
export const useResults = create<ResultsState>((set) => ({
results: null,
setResults: (results: Results) => set({ results }),
clearResults: () => set({ results: null }),
}));

View File

@ -2,33 +2,14 @@ import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import { type ExamPhase } from "../types/sheet"; import { type ExamPhase } from "../types/sheet";
import type { SessionModuleQuestions } from "../types/session"; import type { SessionModuleQuestions } from "../types/session";
import type { Results } from "../types/test";
// interface SatExamState {
// modules: Module[];
// phase: ExamPhase;
// moduleIndex: number;
// questionIndex: number;
// endTime: number | null;
// startExam: () => void;
// setModuleQuestionss: (modules: Module[]) => void;
// nextQuestion: () => void;
// prevQuestion: () => void;
// nextModule: () => void;
// nextPhase: () => void;
// skipBreak: () => void;
// getRemainingTime: () => number;
// finishExam: () => void;
// resetExam: () => void;
// replaceModules: (modules: Module[]) => void;
// }
interface SatExamState { interface SatExamState {
currentModuleQuestions: SessionModuleQuestions | null; currentModuleQuestions: SessionModuleQuestions | null;
phase: ExamPhase; phase: ExamPhase;
questionIndex: number; questionIndex: number;
endTime: number | null; endTime: number | null;
results: Results | null;
setModuleQuestions: (module: SessionModuleQuestions) => void; setModuleQuestions: (module: SessionModuleQuestions) => void;
startExam: () => void; startExam: () => void;
@ -36,10 +17,14 @@ interface SatExamState {
prevQuestion: () => void; prevQuestion: () => void;
goToQuestion: (index: number) => void; goToQuestion: (index: number) => void;
setPhase: (phase: ExamPhase) => void;
startBreak: () => void; startBreak: () => void;
skipBreak: () => void; skipBreak: () => void;
finishExam: () => void; finishExam: () => void;
quitExam: () => void;
resetExam: () => void; resetExam: () => void;
setResults: (results: Results) => void;
getRemainingTime: () => number; getRemainingTime: () => number;
} }
@ -52,6 +37,7 @@ export const useSatExam = create<SatExamState>()(
phase: "IDLE", phase: "IDLE",
questionIndex: 0, questionIndex: 0,
endTime: null, endTime: null,
results: null,
setModuleQuestions: (module: SessionModuleQuestions) => { setModuleQuestions: (module: SessionModuleQuestions) => {
const endTime = Date.now() + module.time_limit_minutes * 1000; const endTime = Date.now() + module.time_limit_minutes * 1000;
@ -117,6 +103,13 @@ export const useSatExam = create<SatExamState>()(
}, },
finishExam: () => set({ phase: "FINISHED", endTime: null }), finishExam: () => set({ phase: "FINISHED", endTime: null }),
quitExam: () => set({ phase: "QUIT", endTime: null }),
setPhase: (phase) => {
set({ phase: phase });
},
setResults: (results: Results) => set({ results }),
getRemainingTime: () => { getRemainingTime: () => {
const { endTime } = get(); const { endTime } = get();
@ -130,8 +123,19 @@ export const useSatExam = create<SatExamState>()(
phase: "IDLE", phase: "IDLE",
questionIndex: 0, questionIndex: 0,
endTime: null, endTime: null,
results: null, // reset results too
}), }),
}), }),
{ name: "sat-exam-storage" }, {
name: "sat-exam-storage",
partialize: (state) => ({
// Only persist things you want to survive reloads
currentModuleQuestions: state.currentModuleQuestions,
phase: state.phase,
questionIndex: state.questionIndex,
endTime: state.endTime,
// Notice: results is NOT included
}),
},
), ),
); );

29
src/types/leaderboard.ts Normal file
View File

@ -0,0 +1,29 @@
export type LeaderboardEntry = {
rank: number;
user_id: string;
name: string;
avatar_url: string;
total_xp: number;
current_level: number;
};
export interface Leaderboard {
top_users: LeaderboardEntry[];
user_rank: LeaderboardEntry;
}
export interface PredictedScore {
total_score: number;
math_prediction: {
score: number;
range_min: number;
range_max: number;
confidence: string;
};
rw_prediction: {
score: number;
range_min: number;
range_max: number;
confidence: string;
};
}

35
src/types/lesson.ts Normal file
View File

@ -0,0 +1,35 @@
import type { Topic } from "./sheet";
export type Lesson = {
id: string;
title: string;
thumbnail_url: string;
topic: Topic;
};
export interface LessonsResponse {
data: Lesson[];
pagination: {
page: number;
limit: number;
total: number;
total_pages: number;
};
}
export interface LessonDetails {
title: string;
topic_id: string;
video_url: string;
content: string;
description: string;
thumbnail_url: string;
resources: any[];
id: string;
created_by: {
id: string;
name: string;
email: string;
};
topic: Topic[];
}

16
src/types/search.ts Normal file
View File

@ -0,0 +1,16 @@
export type SearchItem =
| {
type: "sheet";
id: string;
title: string;
description?: string;
status?: string;
group: string;
}
| {
type: "route";
title: string;
description?: string;
route: string;
group: string;
};

View File

@ -1,4 +1,5 @@
import type { Question } from "./sheet"; import type { Question } from "./sheet";
import type { ExamMode } from "./test";
type Answer = { type Answer = {
id: string; id: string;
@ -14,14 +15,27 @@ type Answer = {
*/ */
export interface SessionRequest { export interface SessionRequest {
sheet_id: string; sheet_id?: string;
mode: string; mode: ExamMode;
topic_ids: string[];
difficulty: string;
question_count: number;
time_limit_minutes: number; time_limit_minutes: number;
topic_ids?: string[];
difficulty?: string;
question_count?: number;
section?: string;
} }
// export interface TargetedSessionResponse {
// id: string;
// practice_sheet_id: null;
// status: string;
// current_module_index: number;
// current_model_id: null;
// current_module_title: null;
// answers: Answer[];
// started_at: string;
// score: number;
// }
export interface SessionResponse { export interface SessionResponse {
id: string; id: string;
practice_sheet_id: string; practice_sheet_id: string;

View File

@ -4,7 +4,7 @@ interface CreatedBy {
email: string; email: string;
} }
export type ExamPhase = "IDLE" | "MODULE" | "BREAK" | "FINISHED"; export type ExamPhase = "IDLE" | "MODULE" | "BREAK" | "FINISHED" | "QUIT";
export type QuestionType = "MCQ" | "TEXT" | "SHORT_ANSWER"; export type QuestionType = "MCQ" | "TEXT" | "SHORT_ANSWER";
@ -25,6 +25,7 @@ export interface Option {
} }
export interface Question { export interface Question {
equation?: string[];
difficulty: string; difficulty: string;
text: string; text: string;
context: string; context: string;

23
src/types/test.ts Normal file
View File

@ -0,0 +1,23 @@
// types/exam.ts
export type ExamMode = "MODULE" | "TARGETED" | "SIMULATION" | "DRILL";
export interface StartExamPayload {
sheet_id: string;
topic_ids: string[];
difficulty: "EASY" | "MEDIUM" | "HARD";
question_count: number;
time_limit_minutes: number;
mode: ExamMode;
}
export interface Results {
score: number;
correct_count: number;
total_questions: number;
xp_gained: number;
leveled_up: boolean;
current_level: number;
total_xp: number;
next_level_threshold: number;
current_level_start: number;
}

8
src/types/topic.ts Normal file
View File

@ -0,0 +1,8 @@
export interface Topic {
name: string;
section: "EBRW" | "MATH";
parent_id: string;
id: string;
slug: string;
parent_name: string;
}

View File

@ -1,3 +1,5 @@
import type { Leaderboard, PredictedScore } from "../types/leaderboard";
import type { Lesson, LessonsResponse } from "../types/lesson";
import type { import type {
SessionAnswerResponse, SessionAnswerResponse,
SessionQuestionsResponse, SessionQuestionsResponse,
@ -6,6 +8,7 @@ import type {
SubmitAnswer, SubmitAnswer,
} from "../types/session"; } from "../types/session";
import type { PracticeSheet } from "../types/sheet"; import type { PracticeSheet } from "../types/sheet";
import type { Topic } from "../types/topic";
const API_URL = "https://ed-dev-api.omukk.dev"; const API_URL = "https://ed-dev-api.omukk.dev";
@ -129,6 +132,29 @@ class ApiClient {
); );
} }
// async startPracticeSession(
// token: string,
// sessionData: PracticeSessionRequest,
// ): Promise<SessionResponse> {
// return this.authenticatedRequest<SessionResponse>(`/sessions/`, token, {
// method: "POST",
// body: JSON.stringify(sessionData),
// });
// }
// async startTargetedPracticeSession(
// token: string,
// sessionData: TargetedSessionRequest,
// ): Promise<TargetedSessionResponse> {
// return this.authenticatedRequest<TargetedSessionResponse>(
// `/sessions/`,
// token,
// {
// method: "POST",
// body: JSON.stringify(sessionData),
// },
// );
// }
async startSession( async startSession(
token: string, token: string,
sessionData: SessionRequest, sessionData: SessionRequest,
@ -183,5 +209,27 @@ class ApiClient {
token, token,
); );
} }
async fetchAllLessons(token: string): Promise<LessonsResponse> {
return this.authenticatedRequest<LessonsResponse>(`/lessons/`, token);
}
async fetchLessonById(token: string, lessonId: string): Promise<Lesson> {
return this.authenticatedRequest<Lesson>(`/lessons/${lessonId}`, token);
}
async fetchAllTopics(token: string): Promise<Topic[]> {
return this.authenticatedRequest<Topic[]>(`/topics/`, token);
}
async fetchTopicById(token: string, topicId: string): Promise<Topic> {
return this.authenticatedRequest<Topic>(`/topics/${topicId}`, token);
}
async fetchLeaderboard(token: string): Promise<Leaderboard> {
return this.authenticatedRequest<Leaderboard>(`/leaderboard/`, token);
}
async fetchPredictedScore(token: string): Promise<PredictedScore> {
return this.authenticatedRequest<PredictedScore>(`/prediction/`, token);
}
} }
export const api = new ApiClient(API_URL); export const api = new ApiClient(API_URL);