Compare commits
13 Commits
9d2ffb5183
...
feat/ui
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c8f945539 | |||
| 626616c8b5 | |||
| b56642fda8 | |||
| e5305a1ca2 | |||
| 96eb2c13b0 | |||
| 7f82e640e0 | |||
| 8cfcb11f0a | |||
| c9db96f97f | |||
| 02419678b7 | |||
| 903653a212 | |||
| 2ac88835f9 | |||
| 62238cbf8f | |||
| 60e858c931 |
@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -17,11 +17,14 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.30.0",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.562.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-katex": "^3.1.0",
|
||||
|
||||
976
pnpm-lock.yaml
generated
976
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
21
src/App.tsx
21
src/App.tsx
@ -16,6 +16,10 @@ import { Test } from "./pages/student/practice/Test";
|
||||
import { Profile } from "./pages/student/Profile";
|
||||
import { Rewards } from "./pages/student/Rewards";
|
||||
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() {
|
||||
const router = createBrowserRouter([
|
||||
@ -50,13 +54,28 @@ function App() {
|
||||
path: "profile",
|
||||
element: <Profile />,
|
||||
},
|
||||
{
|
||||
path: "analytics",
|
||||
element: <Analytics />,
|
||||
},
|
||||
{
|
||||
path: "practice/:sheetId",
|
||||
element: <Pretest />,
|
||||
},
|
||||
{
|
||||
path: "practice/targeted-practice",
|
||||
element: <TargetedPractice />,
|
||||
},
|
||||
{
|
||||
path: "practice/drills",
|
||||
element: <Drills />,
|
||||
},
|
||||
{
|
||||
path: "practice/hard-test-modules",
|
||||
element: <HardTestModules />,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: "practice/:sheetId/test",
|
||||
element: <Test />,
|
||||
|
||||
216
src/components/AppSidebar.tsx
Normal file
216
src/components/AppSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
src/components/Calculator.tsx
Normal file
250
src/components/Calculator.tsx
Normal 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 };
|
||||
34
src/components/ChoiceCard.tsx
Normal file
34
src/components/ChoiceCard.tsx
Normal 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>
|
||||
);
|
||||
145
src/components/CircularLevelProgress.tsx
Normal file
145
src/components/CircularLevelProgress.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
73
src/components/CircularProgress.tsx
Normal file
73
src/components/CircularProgress.tsx
Normal 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 0–1
|
||||
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>
|
||||
);
|
||||
}
|
||||
44
src/components/ConfettiBurst.tsx
Normal file
44
src/components/ConfettiBurst.tsx
Normal 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
|
||||
];
|
||||
93
src/components/GeoGebraGraph.tsx
Normal file
93
src/components/GeoGebraGraph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
634
src/components/GraphPlotter.tsx
Normal file
634
src/components/GraphPlotter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
22
src/components/LeaderboardSkeleton.tsx
Normal file
22
src/components/LeaderboardSkeleton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
src/components/LessonModal.tsx
Normal file
90
src/components/LessonModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
11
src/components/LessonSkeleton.tsx
Normal file
11
src/components/LessonSkeleton.tsx
Normal 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>
|
||||
);
|
||||
305
src/components/PredictedScoreCard.tsx
Normal file
305
src/components/PredictedScoreCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
232
src/components/SearchOverlay.tsx
Normal file
232
src/components/SearchOverlay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
31
src/components/ui/collapsible.tsx
Normal file
31
src/components/ui/collapsible.tsx
Normal 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
246
src/components/ui/field.tsx
Normal 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,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
||||
22
src/components/ui/label.tsx
Normal file
22
src/components/ui/label.tsx
Normal 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 }
|
||||
29
src/components/ui/progress.tsx
Normal file
29
src/components/ui/progress.tsx
Normal 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 };
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal 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
143
src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
726
src/components/ui/sidebar.tsx
Normal file
726
src/components/ui/sidebar.tsx
Normal 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,
|
||||
}
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal 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 }
|
||||
55
src/components/ui/tooltip.tsx
Normal file
55
src/components/ui/tooltip.tsx
Normal 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
19
src/hooks/use-mobile.ts
Normal 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
|
||||
}
|
||||
@ -281,3 +281,38 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -81,3 +81,33 @@ export const formatTime = (seconds: number) => {
|
||||
const s = seconds % 60;
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
12
src/main.tsx
12
src/main.tsx
@ -1,10 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
|
||||
71
src/pages/student/Analytics.tsx
Normal file
71
src/pages/student/Analytics.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -6,7 +6,7 @@ import {
|
||||
TabsContent,
|
||||
} from "../../components/ui/tabs";
|
||||
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 {
|
||||
Card,
|
||||
@ -21,18 +21,28 @@ import { Button } from "../../components/ui/button";
|
||||
import type { PracticeSheet } from "../../types/sheet";
|
||||
import { formatStatus } from "../../lib/utils";
|
||||
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 = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const navigate = useNavigate();
|
||||
const userXp = useExamConfigStore.getState().userXp;
|
||||
|
||||
// const logout = useAuthStore((state) => state.logout);
|
||||
// const navigate = useNavigate();
|
||||
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
|
||||
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
|
||||
const [inProgressSheets, setInProgressSheets] = useState<PracticeSheet[]>([]);
|
||||
const [completedSheets, setCompletedSheets] = useState<PracticeSheet[]>([]);
|
||||
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const sortPracticeSheets = (sheets: PracticeSheet[]) => {
|
||||
const notStarted = sheets.filter(
|
||||
@ -68,7 +78,6 @@ export const Home = () => {
|
||||
}
|
||||
const sheets = await api.getPracticeSheets(token, 1, 10);
|
||||
setPracticeSheets(sheets.data);
|
||||
console.log("All Practice Sheets: ", sheets.data);
|
||||
sortPracticeSheets(sheets.data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching practice sheets:", error);
|
||||
@ -78,25 +87,62 @@ export const Home = () => {
|
||||
fetchPracticeSheets();
|
||||
}, [user]);
|
||||
|
||||
const handleStartPractice = (sheetId: string) => {
|
||||
const handleStartPracticeSheet = (sheetId: string) => {
|
||||
navigate(`/student/practice/${sheetId}`);
|
||||
};
|
||||
|
||||
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">
|
||||
<h1 className="text-4xl font-satoshi-bold tracking-tight text-gray-800 text-center">
|
||||
Welcome, {user?.name || "Student"}
|
||||
<main className="min-h-screen space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-12">
|
||||
<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"}
|
||||
</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">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
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"
|
||||
onFocus={() => setIsSearchOpen(true)}
|
||||
placeholder="Search practice sheets..."
|
||||
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">
|
||||
<Search size={22} color="gray" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
||||
Pick up where you left off
|
||||
@ -105,7 +151,7 @@ export const Home = () => {
|
||||
inProgressSheets.map((sheet) => (
|
||||
<Card
|
||||
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>
|
||||
<CardTitle className="font-satoshi-medium text-xl">
|
||||
@ -116,7 +162,7 @@ export const Home = () => {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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)}
|
||||
</p>
|
||||
<Badge
|
||||
@ -133,9 +179,9 @@ export const Home = () => {
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
onClick={() => handleStartPractice(sheet?.id)}
|
||||
onClick={() => handleStartPracticeSheet(sheet?.id)}
|
||||
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
|
||||
</Button>
|
||||
@ -155,19 +201,19 @@ export const Home = () => {
|
||||
<TabsList className="bg-transparent p-0 w-full">
|
||||
<TabsTrigger
|
||||
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
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
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
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
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
|
||||
</TabsTrigger>
|
||||
@ -203,9 +249,9 @@ export const Home = () => {
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
onClick={() => handleStartPractice(sheet?.id)}
|
||||
onClick={() => handleStartPracticeSheet(sheet?.id)}
|
||||
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
|
||||
</Button>
|
||||
@ -250,7 +296,7 @@ export const Home = () => {
|
||||
<CardFooter>
|
||||
<Button
|
||||
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
|
||||
</Button>
|
||||
@ -291,7 +337,7 @@ export const Home = () => {
|
||||
<CardFooter>
|
||||
<Button
|
||||
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
|
||||
</Button>
|
||||
@ -316,35 +362,46 @@ export const Home = () => {
|
||||
</h1>
|
||||
<section className="space-y-4 ">
|
||||
<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">
|
||||
Practice regularly with official SAT materials
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
Review your mistakes and learn from them
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
Take full-length practice tests
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
Get plenty of rest before the test day
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{isSearchOpen && (
|
||||
<SearchOverlay
|
||||
sheets={practiceSheets}
|
||||
onClose={() => {
|
||||
setIsSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
// import { useAuthStore } from "../../stores/authStore";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@ -12,9 +13,52 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} 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 = () => {
|
||||
// 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 (
|
||||
<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>
|
||||
<TabsContent value="rw" className="pt-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
{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">
|
||||
<img
|
||||
src={lesson.thumbnail_url}
|
||||
alt={lesson.title}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2">
|
||||
<CardTitle>{lesson.title}</CardTitle>
|
||||
<CardDescription>{lesson.topic.name}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<LessonModal
|
||||
open={isModalOpen}
|
||||
lessonId={selectedLessonId}
|
||||
onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) setSelectedLessonId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="math" className="pt-4">
|
||||
<Card className="py-0 pb-8 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>
|
||||
{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">
|
||||
<img
|
||||
src={lesson.thumbnail_url}
|
||||
alt={lesson.title}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2">
|
||||
<CardTitle>{lesson.title}</CardTitle>
|
||||
<CardDescription>{lesson.topic.name}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<LessonModal
|
||||
open={isModalOpen}
|
||||
lessonId={selectedLessonId}
|
||||
onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) setSelectedLessonId(null);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
@ -17,22 +17,27 @@ import {
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
||||
|
||||
export const Practice = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const userXp = useExamConfigStore.getState().userXp;
|
||||
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="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="px-8 py-8 space-y-4">
|
||||
<header className="flex justify-between items-center ">
|
||||
<div className="w-fit bg-linear-to-br from-indigo-500 to-indigo-600 p-3 rounded-2xl">
|
||||
<BookOpen size={20} color="white" />
|
||||
</div>
|
||||
<div className="bg-purple-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>
|
||||
<span className="font-satoshi-bold text-md">0</span>
|
||||
<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-indigo-400 to-indigo-500 rounded-full"></div>
|
||||
<span className="font-satoshi-bold text-md">{userXp}</span>
|
||||
</div>
|
||||
</header>
|
||||
<section>
|
||||
<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"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@ -61,7 +66,10 @@ export const Practice = () => {
|
||||
<section className="flex flex-col gap-6">
|
||||
<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">
|
||||
<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">
|
||||
<div className="w-fit bg-linear-to-br from-red-400 to-red-500 p-3 rounded-2xl">
|
||||
<Target size={20} color="white" />
|
||||
@ -81,7 +89,10 @@ export const Practice = () => {
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</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">
|
||||
<div className="w-fit bg-linear-to-br from-cyan-400 to-cyan-500 p-3 rounded-2xl">
|
||||
<Zap size={20} color="white" />
|
||||
@ -99,7 +110,10 @@ export const Practice = () => {
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</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">
|
||||
<div className="w-fit bg-linear-to-br from-lime-400 to-lime-500 p-3 rounded-2xl">
|
||||
<Trophy size={20} color="white" />
|
||||
@ -121,6 +135,6 @@ export const Practice = () => {
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -12,7 +12,7 @@ export const Profile = () => {
|
||||
};
|
||||
|
||||
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>
|
||||
<section>
|
||||
<h3 className="text-2xl font-satoshi-bold">{user?.name}</h3>
|
||||
@ -63,7 +63,7 @@ export const Profile = () => {
|
||||
</section>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
||||
@ -3,7 +3,7 @@ import firstTrophy from "../../assets/icons/first_trophy.png";
|
||||
import secondTrophy from "../../assets/icons/second_trophy.png";
|
||||
import thirdTrophy from "../../assets/icons/third_trophy.png";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
// import {
|
||||
// Card,
|
||||
// CardHeader,
|
||||
@ -27,34 +27,89 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../../components/ui/dropdown-menu";
|
||||
import { formatTimeFilter, getRandomColor } from "../../lib/utils";
|
||||
import { Avatar, AvatarFallback } from "../../components/ui/avatar";
|
||||
import { Zap } from "lucide-react";
|
||||
import {
|
||||
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 = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
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 isTopThree = (leaderboard?.user_rank?.rank ?? Infinity) < 3;
|
||||
|
||||
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">
|
||||
<h1 className="font-satoshi-black text-3xl">Leaderboards</h1>
|
||||
<p className="font-satoshi-medium text-md text-gray-500">
|
||||
Complete lessons to rise to the top.{" "}
|
||||
<span className="underline">Start a lesson.</span>
|
||||
</p>
|
||||
{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">
|
||||
Don't stop now! You're{" "}
|
||||
<span className="text-indigo-400">
|
||||
#{leaderboard?.user_rank.rank}
|
||||
</span>{" "}
|
||||
in XP.
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
<section className="w-full">
|
||||
<Tabs defaultValue="xp" className="space-y-6">
|
||||
<TabsList className="bg-transparent p-0 w-full justify-between ">
|
||||
<section className="w-full px-7">
|
||||
<Tabs
|
||||
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
|
||||
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"
|
||||
@ -100,49 +155,64 @@ export const Rewards = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TabsList>
|
||||
<TabsContent value="xp" className="space-y-6">
|
||||
{leaderboard.map((user, index) => {
|
||||
const isTopThree = index < 3;
|
||||
<TabsContent
|
||||
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;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex justify-between items-center"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isTopThree ? (
|
||||
<img
|
||||
src={trophies[index]}
|
||||
alt={`trophy_${index + 1}`}
|
||||
className="w-12 h-12"
|
||||
/>
|
||||
) : (
|
||||
<span className="w-12 text-center font-satoshi-bold text-gray-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
return (
|
||||
<div
|
||||
key={user.user_id}
|
||||
className="flex justify-between items-center"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isTopThree ? (
|
||||
<img
|
||||
src={trophies[index]}
|
||||
alt={`trophy_${index + 1}`}
|
||||
className="w-12 h-12"
|
||||
/>
|
||||
) : (
|
||||
<span className="w-12 text-center font-satoshi-bold text-gray-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Avatar className={`p-6 ${getRandomColor()}`}>
|
||||
<AvatarFallback className="text-white font-satoshi-bold">
|
||||
{user.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar className={`p-6 ${getRandomColor()}`}>
|
||||
<AvatarImage src={user.avatar_url} />
|
||||
<AvatarFallback className="text-white font-satoshi-bold">
|
||||
{user.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<p className="font-satoshi-medium text-gray-600">
|
||||
{user.name}
|
||||
</p>
|
||||
<p className="font-satoshi-medium text-gray-600">
|
||||
{user.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-satoshi-medium">{user.total_xp}</p>
|
||||
<Zap size={20} className="text-lime-500 fill-lime-200" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-satoshi-medium">{user.xp}</p>
|
||||
<Zap size={20} color="darkgreen" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="questions" className="space-y-6">
|
||||
{leaderboard.map((user, index) => {
|
||||
<TabsContent
|
||||
value="questions"
|
||||
className="flex-1 overflow-y-auto space-y-6"
|
||||
>
|
||||
{/* {leaderboard.map((user, index) => {
|
||||
const isTopThree = index < 3;
|
||||
|
||||
return (
|
||||
@ -180,10 +250,13 @@ export const Rewards = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
})} */}
|
||||
</TabsContent>
|
||||
<TabsContent value="streak" className="space-y-6">
|
||||
{leaderboard.map((user, index) => {
|
||||
<TabsContent
|
||||
value="streak"
|
||||
className="flex-1 overflow-y-auto space-y-6"
|
||||
>
|
||||
{/* {leaderboard.map((user, index) => {
|
||||
const isTopThree = index < 3;
|
||||
|
||||
return (
|
||||
@ -221,10 +294,79 @@ export const Rewards = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
})} */}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { Outlet, NavLink } from "react-router-dom";
|
||||
import { Home, BookOpen, Award, User, Video } from "lucide-react";
|
||||
import { SidebarProvider, SidebarTrigger } from "../../components/ui/sidebar";
|
||||
import { AppSidebar } from "../../components/AppSidebar";
|
||||
|
||||
export function StudentLayout() {
|
||||
const navItems = [
|
||||
@ -11,51 +13,58 @@ export function StudentLayout() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-50">
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 pb-20 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
<SidebarProvider>
|
||||
<div className="flex min-h-screen w-full overflow-x-hidden">
|
||||
{/* Desktop Sidebar */}
|
||||
<AppSidebar />
|
||||
|
||||
{/* Bottom Tab Navigation */}
|
||||
<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">
|
||||
<div className="max-w-7xl mx-auto px-2">
|
||||
<div className="flex justify-around items-center">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center justify-center py-3 px-4 flex-1 transition-all duration-200 font-satoshi tracking-wide ${
|
||||
isActive
|
||||
? "text-purple-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<item.icon
|
||||
size={24}
|
||||
className={`mb-1 transition-transform ${
|
||||
isActive ? "scale-110" : ""
|
||||
}`}
|
||||
strokeWidth={isActive ? 2.5 : 2}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
isActive ? "font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<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 />
|
||||
</main>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom nav */}
|
||||
<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="flex justify-around items-center">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center justify-center py-3 px-4 flex-1 transition-all duration-200 font-satoshi tracking-wide ${
|
||||
isActive
|
||||
? "text-indigo-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<item.icon
|
||||
size={24}
|
||||
className={`mb-1 transition-transform ${
|
||||
isActive ? "scale-110" : ""
|
||||
}`}
|
||||
strokeWidth={isActive ? 2.5 : 2}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
isActive ? "font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
212
src/pages/student/drills/page.tsx
Normal file
212
src/pages/student/drills/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
119
src/pages/student/hard-test-modules/page.tsx
Normal file
119
src/pages/student/hard-test-modules/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -1,16 +1,9 @@
|
||||
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 { useAuthStore } from "../../../stores/authStore";
|
||||
import type { PracticeSheet } from "../../../types/sheet";
|
||||
import {
|
||||
CircleQuestionMark,
|
||||
Clock,
|
||||
Layers,
|
||||
Loader,
|
||||
Loader2,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { CircleQuestionMark, Clock, Layers, Loader, Tag } from "lucide-react";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
@ -19,8 +12,11 @@ import {
|
||||
} from "../../../components/ui/carousel";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||
|
||||
export const Pretest = () => {
|
||||
const { setSheetId, setMode, storeDuration, setQuestionCount } =
|
||||
useExamConfigStore();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const { sheetId } = useParams<{ sheetId: string }>();
|
||||
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
|
||||
@ -37,6 +33,12 @@ export const Pretest = () => {
|
||||
console.error("Sheet ID is required to start the test.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSheetId(sheetId);
|
||||
setMode("SIMULATION");
|
||||
storeDuration(practiceSheet?.time_limit ?? 0);
|
||||
setQuestionCount(2);
|
||||
|
||||
navigate(`/student/practice/${sheetId}/test`, { replace: true });
|
||||
}
|
||||
|
||||
@ -73,7 +75,7 @@ export const Pretest = () => {
|
||||
}, [carouselApi]);
|
||||
|
||||
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">
|
||||
<h1 className="text-4xl font-satoshi-bold">{practiceSheet?.title}</h1>
|
||||
<p className="text-lg font-satoshi text-gray-700">
|
||||
@ -117,8 +119,8 @@ export const Pretest = () => {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<Carousel setApi={setCarouselApi}>
|
||||
<CarouselContent className="">
|
||||
<Carousel className="" setApi={setCarouselApi}>
|
||||
<CarouselContent>
|
||||
{practiceSheet ? (
|
||||
practiceSheet.modules.length > 0 ? (
|
||||
practiceSheet.modules.map((module, index) => (
|
||||
@ -130,7 +132,7 @@ export const Pretest = () => {
|
||||
<p className="text-lg font-satoshi text-gray-700">
|
||||
{module.title}
|
||||
</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="w-fit bg-cyan-100 p-2 rounded-full">
|
||||
<Clock size={30} color="oklch(60.9% 0.126 221.723)" />
|
||||
@ -199,7 +201,7 @@ export const Pretest = () => {
|
||||
<div
|
||||
key={index}
|
||||
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>
|
||||
))}
|
||||
@ -214,7 +216,7 @@ export const Pretest = () => {
|
||||
<Button
|
||||
onClick={() => handleStartTest(practiceSheet?.id!)}
|
||||
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}
|
||||
>
|
||||
{practiceSheet ? (
|
||||
@ -225,6 +227,6 @@ export const Pretest = () => {
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,12 +1,251 @@
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center text-2xl font-satoshi-bold">
|
||||
Your results go here
|
||||
<Button onClick={() => navigate("/student/home")}>Go to home</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>XP</CardTitle>
|
||||
<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 — 0–100 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
262
src/pages/student/targeted-practice/page.tsx
Normal file
262
src/pages/student/targeted-practice/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
94
src/stores/useExamConfigStore.ts
Normal file
94
src/stores/useExamConfigStore.ts
Normal 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
16
src/stores/useResults.ts
Normal 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 }),
|
||||
}));
|
||||
@ -2,33 +2,14 @@ import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { type ExamPhase } from "../types/sheet";
|
||||
import type { SessionModuleQuestions } from "../types/session";
|
||||
|
||||
// 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;
|
||||
// }
|
||||
import type { Results } from "../types/test";
|
||||
|
||||
interface SatExamState {
|
||||
currentModuleQuestions: SessionModuleQuestions | null;
|
||||
phase: ExamPhase;
|
||||
questionIndex: number;
|
||||
endTime: number | null;
|
||||
results: Results | null;
|
||||
|
||||
setModuleQuestions: (module: SessionModuleQuestions) => void;
|
||||
startExam: () => void;
|
||||
@ -36,10 +17,14 @@ interface SatExamState {
|
||||
prevQuestion: () => void;
|
||||
goToQuestion: (index: number) => void;
|
||||
|
||||
setPhase: (phase: ExamPhase) => void;
|
||||
|
||||
startBreak: () => void;
|
||||
skipBreak: () => void;
|
||||
finishExam: () => void;
|
||||
quitExam: () => void;
|
||||
resetExam: () => void;
|
||||
setResults: (results: Results) => void;
|
||||
getRemainingTime: () => number;
|
||||
}
|
||||
|
||||
@ -52,6 +37,7 @@ export const useSatExam = create<SatExamState>()(
|
||||
phase: "IDLE",
|
||||
questionIndex: 0,
|
||||
endTime: null,
|
||||
results: null,
|
||||
|
||||
setModuleQuestions: (module: SessionModuleQuestions) => {
|
||||
const endTime = Date.now() + module.time_limit_minutes * 1000;
|
||||
@ -117,6 +103,13 @@ export const useSatExam = create<SatExamState>()(
|
||||
},
|
||||
|
||||
finishExam: () => set({ phase: "FINISHED", endTime: null }),
|
||||
quitExam: () => set({ phase: "QUIT", endTime: null }),
|
||||
|
||||
setPhase: (phase) => {
|
||||
set({ phase: phase });
|
||||
},
|
||||
|
||||
setResults: (results: Results) => set({ results }),
|
||||
|
||||
getRemainingTime: () => {
|
||||
const { endTime } = get();
|
||||
@ -130,8 +123,19 @@ export const useSatExam = create<SatExamState>()(
|
||||
phase: "IDLE",
|
||||
questionIndex: 0,
|
||||
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
29
src/types/leaderboard.ts
Normal 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
35
src/types/lesson.ts
Normal 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
16
src/types/search.ts
Normal 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;
|
||||
};
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Question } from "./sheet";
|
||||
import type { ExamMode } from "./test";
|
||||
|
||||
type Answer = {
|
||||
id: string;
|
||||
@ -14,14 +15,27 @@ type Answer = {
|
||||
*/
|
||||
|
||||
export interface SessionRequest {
|
||||
sheet_id: string;
|
||||
mode: string;
|
||||
topic_ids: string[];
|
||||
difficulty: string;
|
||||
question_count: number;
|
||||
sheet_id?: string;
|
||||
mode: ExamMode;
|
||||
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 {
|
||||
id: string;
|
||||
practice_sheet_id: string;
|
||||
|
||||
@ -4,7 +4,7 @@ interface CreatedBy {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export type ExamPhase = "IDLE" | "MODULE" | "BREAK" | "FINISHED";
|
||||
export type ExamPhase = "IDLE" | "MODULE" | "BREAK" | "FINISHED" | "QUIT";
|
||||
|
||||
export type QuestionType = "MCQ" | "TEXT" | "SHORT_ANSWER";
|
||||
|
||||
@ -25,6 +25,7 @@ export interface Option {
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
equation?: string[];
|
||||
difficulty: string;
|
||||
text: string;
|
||||
context: string;
|
||||
|
||||
23
src/types/test.ts
Normal file
23
src/types/test.ts
Normal 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
8
src/types/topic.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface Topic {
|
||||
name: string;
|
||||
section: "EBRW" | "MATH";
|
||||
parent_id: string;
|
||||
id: string;
|
||||
slug: string;
|
||||
parent_name: string;
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import type { Leaderboard, PredictedScore } from "../types/leaderboard";
|
||||
import type { Lesson, LessonsResponse } from "../types/lesson";
|
||||
import type {
|
||||
SessionAnswerResponse,
|
||||
SessionQuestionsResponse,
|
||||
@ -6,6 +8,7 @@ import type {
|
||||
SubmitAnswer,
|
||||
} from "../types/session";
|
||||
import type { PracticeSheet } from "../types/sheet";
|
||||
import type { Topic } from "../types/topic";
|
||||
|
||||
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(
|
||||
token: string,
|
||||
sessionData: SessionRequest,
|
||||
@ -183,5 +209,27 @@ class ApiClient {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user