web #1

Merged
shafin808s merged 35 commits from web into main 2026-03-11 20:41:06 +00:00
29 changed files with 8404 additions and 2458 deletions
Showing only changes of commit f054c7179b - Show all commits

View File

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

View File

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

View File

@ -1,6 +1,18 @@
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Search, X } from "lucide-react"; import {
Search,
X,
BookOpen,
Zap,
Target,
Trophy,
User,
Home,
ArrowRight,
Clock,
Flame,
} from "lucide-react";
import type { PracticeSheet } from "../types/sheet"; import type { PracticeSheet } from "../types/sheet";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import type { SearchItem } from "../types/search"; import type { SearchItem } from "../types/search";
@ -13,20 +25,32 @@ interface Props {
setSearchQuery: (value: string) => void; setSearchQuery: (value: string) => void;
} }
const navigationItems: SearchItem[] = [ // ─── Nav items ────────────────────────────────────────────────────────────────
const NAV_ITEMS: (SearchItem & {
icon: React.ElementType;
color: string;
bg: string;
})[] = [
{ {
type: "route", type: "route",
title: "Hard Test Modules", title: "Hard Test Modules",
description: "Access advanced SAT modules", description: "Tackle the hardest SAT questions",
route: "/student/hard-test-modules", route: "/student/hard-test-modules",
group: "Pages", group: "Pages",
icon: Trophy,
color: "#84cc16",
bg: "#f7ffe4",
}, },
{ {
type: "route", type: "route",
title: "Targeted Practice", title: "Targeted Practice",
description: "Focus on what matters", description: "Focus on your weak spots",
route: "/student/practice/targeted-practice", route: "/student/practice/targeted-practice",
group: "Pages", group: "Pages",
icon: Target,
color: "#ef4444",
bg: "#fff5f5",
}, },
{ {
type: "route", type: "route",
@ -34,64 +58,291 @@ const navigationItems: SearchItem[] = [
description: "Train speed and accuracy", description: "Train speed and accuracy",
route: "/student/practice/drills", route: "/student/practice/drills",
group: "Pages", group: "Pages",
icon: Zap,
color: "#0891b2",
bg: "#ecfeff",
}, },
{ {
type: "route", type: "route",
title: "Leaderboard", title: "Leaderboard",
description: "View student rankings", description: "See how you rank against others",
route: "/student/rewards", route: "/student/rewards",
group: "Pages", group: "Pages",
icon: Trophy,
color: "#f97316",
bg: "#fff7ed",
}, },
{ {
type: "route", type: "route",
title: "Practice", title: "Practice",
description: "See how you can practice", description: "Browse all practice modes",
route: "/student/practice", route: "/student/practice",
group: "Pages", group: "Pages",
icon: BookOpen,
color: "#a855f7",
bg: "#fdf4ff",
}, },
{ {
type: "route", type: "route",
title: "Lessons", title: "Lessons",
description: "Watch detailed lessons on SAT techniques", description: "Watch expert SAT technique lessons",
route: "/student/lessons", route: "/student/lessons",
group: "Pages", group: "Pages",
icon: BookOpen,
color: "#0891b2",
bg: "#ecfeff",
}, },
{ {
type: "route", type: "route",
title: "Profile", title: "Profile",
description: "View your profile", description: "View your profile and settings",
route: "/student/profile", route: "/student/profile",
group: "Pages", group: "Pages",
icon: User,
color: "#e11d48",
bg: "#fff1f2",
},
{
type: "route",
title: "Home",
description: "Go back to home",
route: "/student/home",
group: "Pages",
icon: Home,
color: "#f97316",
bg: "#fff7ed",
}, },
]; ];
const highlightText = (text: string, query: string) => { const NAV_MAP = Object.fromEntries(NAV_ITEMS.map((n) => [n.route, n]));
if (!query.trim()) return text;
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const STATUS_META = {
const regex = new RegExp(`(${escapedQuery})`, "gi"); IN_PROGRESS: {
label: "In Progress",
const parts = text.split(regex); color: "#9333ea",
bg: "#f3e8ff",
return parts.map((part, index) => { icon: "🔄",
const isMatch = part.toLowerCase() === query.toLowerCase(); },
COMPLETED: {
return isMatch ? ( label: "Completed",
<motion.span color: "#16a34a",
key={index} bg: "#f0fdf4",
initial={{ opacity: 0, scale: 0.95 }} icon: "✅",
animate={{ opacity: 1, scale: 1 }} },
transition={{ duration: 0.2, delay: index * 0.05 }} NOT_STARTED: {
className="bg-purple-200 text-purple-900 px-1 rounded-md" label: "Not Started",
> color: "#6b7280",
{part} bg: "#f3f4f6",
</motion.span> icon: "📋",
) : ( },
part
);
});
}; };
// ─── Recent items (session memory) ───────────────────────────────────────────
const SESSION_KEY = "so_recent";
const MAX_RECENT = 5;
const getRecent = (): SearchItem[] => {
try {
return JSON.parse(sessionStorage.getItem(SESSION_KEY) ?? "[]");
} catch {
return [];
}
};
const addRecent = (item: SearchItem) => {
const prev = getRecent().filter((r) => r.route !== item.route);
const next = [item, ...prev].slice(0, MAX_RECENT);
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
};
// ─── Highlight helper ─────────────────────────────────────────────────────────
const highlightText = (text: string, query: string) => {
if (!query.trim()) return <>{text}</>;
const esc = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${esc})`, "gi");
const parts = text.split(regex);
return (
<>
{parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark
key={i}
style={{
background: "#e9d5ff",
color: "#6b21a8",
borderRadius: 4,
padding: "0 2px",
}}
>
{part}
</mark>
) : (
part
),
)}
</>
);
};
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
.so-overlay {
position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,0.35);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex; flex-direction: column;
align-items: center; padding-top: 5rem;
padding-left: 1rem; padding-right: 1rem;
}
.so-box {
width: 100%; max-width: 560px;
background: #fffbf4;
border: 2.5px solid #f3f4f6;
border-radius: 28px;
box-shadow: 0 20px 60px rgba(0,0,0,0.18), 0 6px 16px rgba(0,0,0,0.08);
overflow: hidden;
display: flex; flex-direction: column;
max-height: calc(100vh - 6rem);
}
/* Input row */
.so-input-row {
display: flex; align-items: center; gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 2px solid #f3f4f6;
flex-shrink: 0;
}
.so-input {
flex: 1; outline: none; border: none; background: transparent;
font-family: 'Nunito', sans-serif;
font-size: 0.95rem; font-weight: 800; color: #1e1b4b;
}
.so-input::placeholder { color: #d1d5db; font-weight: 700; }
.so-close-btn {
width: 30px; height: 30px; border-radius: 50%; border: 2.5px solid #f3f4f6;
background: white; cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s ease;
}
.so-close-btn:hover { border-color: #fecdd3; background: #fff1f2; }
/* Scrollable results */
.so-results {
overflow-y: auto; flex: 1;
padding: 0.75rem 0.75rem 1rem;
-webkit-overflow-scrolling: touch;
display: flex; flex-direction: column; gap: 1rem;
}
/* Section label */
.so-section-label {
font-size: 0.58rem; font-weight: 800; letter-spacing: 0.18em;
text-transform: uppercase; color: #9ca3af;
padding: 0 0.5rem; margin-bottom: -0.35rem;
display: flex; align-items: center; gap: 0.4rem;
}
/* Result rows */
.so-item {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.7rem 0.75rem; border-radius: 16px; cursor: pointer;
transition: background 0.15s ease, transform 0.1s ease;
border: 2px solid transparent;
}
.so-item:hover {
background: white; border-color: #f3f4f6;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
transform: translateX(2px);
}
.so-item:active { transform: scale(0.98); }
.so-item-icon {
width: 36px; height: 36px; border-radius: 11px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 0.85rem;
}
.so-item-body { flex: 1; min-width: 0; }
.so-item-title {
font-family: 'Nunito', sans-serif;
font-size: 0.88rem; font-weight: 900; color: #1e1b4b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.so-item-desc {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.72rem; font-weight: 600; color: #9ca3af;
margin-top: 0.05rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.so-item-arrow { color: #d1d5db; flex-shrink: 0; transition: color 0.15s ease; }
.so-item:hover .so-item-arrow { color: #a855f7; }
/* Sheet status chip inline */
.so-status-chip {
font-size: 0.6rem; font-weight: 800; letter-spacing: 0.08em;
text-transform: uppercase; border-radius: 100px; padding: 0.15rem 0.5rem;
flex-shrink: 0;
}
/* Quick nav chips (shown when empty query) */
.so-quick-wrap {
display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0 0.25rem;
}
.so-quick-chip {
display: flex; align-items: center; gap: 0.4rem;
background: white; border: 2.5px solid #f3f4f6; border-radius: 100px;
padding: 0.45rem 0.85rem; cursor: pointer;
font-family: 'Nunito', sans-serif; font-size: 0.75rem; font-weight: 800;
color: #6b7280;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
transition: all 0.15s ease;
}
.so-quick-chip:hover { transform: translateY(-2px); box-shadow: 0 6px 14px rgba(0,0,0,0.07); border-color: #e9d5ff; color: #a855f7; }
/* Empty state */
.so-empty {
display: flex; flex-direction: column; align-items: center;
padding: 2rem 1rem; gap: 0.5rem;
font-family: 'Nunito Sans', sans-serif;
}
.so-empty-emoji { font-size: 2rem; }
.so-empty-text { font-size: 0.85rem; font-weight: 700; color: #9ca3af; }
.so-empty-sub { font-size: 0.75rem; font-weight: 600; color: #d1d5db; text-align: center; }
/* Keyboard hint */
.so-kbd-row {
display: flex; align-items: center; justify-content: center; gap: 1rem;
padding: 0.6rem 1rem;
border-top: 2px solid #f9fafb;
flex-shrink: 0;
}
.so-kbd-hint {
display: flex; align-items: center; gap: 0.3rem;
font-family: 'Nunito Sans', sans-serif;
font-size: 0.62rem; font-weight: 600; color: #d1d5db;
}
.so-kbd {
background: white; border: 1.5px solid #e5e7eb; border-radius: 5px;
padding: 0.1rem 0.4rem; font-size: 0.6rem; font-weight: 800;
color: #9ca3af; box-shadow: 0 1px 0 #d1d5db;
}
/* Highlight count badge */
.so-count-badge {
font-family: 'Nunito', sans-serif;
font-size: 0.65rem; font-weight: 800;
background: #f3e8ff; color: #9333ea;
border-radius: 100px; padding: 0.15rem 0.5rem; flex-shrink: 0;
}
`;
// ─── Main component ───────────────────────────────────────────────────────────
export const SearchOverlay = ({ export const SearchOverlay = ({
sheets, sheets,
onClose, onClose,
@ -99,131 +350,330 @@ export const SearchOverlay = ({
setSearchQuery, setSearchQuery,
}: Props) => { }: Props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const [recent, setRecent] = useState<SearchItem[]>(getRecent);
const [focused, setFocused] = useState(-1); // keyboard nav index
// Build full search item list
const searchItems = useMemo<SearchItem[]>(() => { const searchItems = useMemo<SearchItem[]>(() => {
const sheetItems = sheets.map((sheet) => ({ const sheetItems = sheets.map((sheet) => ({
type: "sheet", type: "sheet" as const,
id: sheet.id, id: sheet.id,
title: sheet.title, title: sheet.title,
description: sheet.description, description: sheet.description ?? "Practice sheet",
route: `/student/practice/${sheet.id}`, route: `/student/practice/${sheet.id}`,
group: formatGroupTitle(sheet.user_status), // 👈 reuse your grouping group: formatGroupTitle(sheet.user_status),
status: sheet.user_status,
})); }));
return [...NAV_ITEMS, ...sheetItems];
return [...navigationItems, ...sheetItems];
}, [sheets]); }, [sheets]);
// Close on ESC // Filtered + grouped results
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
const groupedResults = useMemo(() => { const groupedResults = useMemo(() => {
if (!searchQuery.trim()) return {}; if (!searchQuery.trim()) return {};
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
const filtered = searchItems.filter(
const filtered = searchItems.filter((item) => { (item) =>
const title = item.title?.toLowerCase() || ""; item.title?.toLowerCase().includes(q) ||
const description = item.description?.toLowerCase() || ""; item.description?.toLowerCase().includes(q),
);
return title.includes(q) || description.includes(q);
});
return filtered.reduce<Record<string, SearchItem[]>>((acc, item) => { return filtered.reduce<Record<string, SearchItem[]>>((acc, item) => {
if (!acc[item.group]) { (acc[item.group] ??= []).push(item);
acc[item.group] = [];
}
acc[item.group].push(item);
return acc; return acc;
}, {}); }, {});
}, [searchQuery, searchItems]); }, [searchQuery, searchItems]);
const flatResults = useMemo(
() => Object.values(groupedResults).flat(),
[groupedResults],
);
// ESC to close, arrow keys + enter for keyboard nav
useEffect(() => {
const handle = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setFocused((f) => Math.min(f + 1, flatResults.length - 1));
}
if (e.key === "ArrowUp") {
e.preventDefault();
setFocused((f) => Math.max(f - 1, 0));
}
if (e.key === "Enter" && focused >= 0 && flatResults[focused]) {
handleSelect(flatResults[focused]);
}
};
window.addEventListener("keydown", handle);
return () => window.removeEventListener("keydown", handle);
}, [onClose, focused, flatResults]);
// Reset focused when query changes
useEffect(() => {
setFocused(-1);
}, [searchQuery]);
const handleSelect = (item: SearchItem) => {
addRecent(item);
setRecent(getRecent());
onClose();
navigate(item.route!);
};
const totalCount = flatResults.length;
return ( return (
<AnimatePresence> <AnimatePresence>
<motion.div <motion.div
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm flex flex-col items-center pt-24 px-4" className="so-overlay"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
onClick={onClose} onClick={onClose}
> >
{/* Search Box */} <style>{STYLES}</style>
<motion.div <motion.div
initial={{ y: -40, opacity: 0 }} className="so-box"
animate={{ y: 0, opacity: 1 }} initial={{ y: -24, opacity: 0, scale: 0.97 }}
exit={{ y: -40, opacity: 0 }} animate={{ y: 0, opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 300 }} exit={{ y: -24, opacity: 0, scale: 0.97 }}
transition={{ type: "spring", stiffness: 380, damping: 28 }}
onClick={(e) => e.stopPropagation()} 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"> {/* Input row */}
<Search size={20} /> <div className="so-input-row">
<Search size={18} color="#9ca3af" />
<input <input
ref={inputRef}
autoFocus autoFocus
className="so-input"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..." placeholder="Search sheets, pages, topics..."
className="flex-1 outline-none font-satoshi text-lg"
/> />
<button onClick={onClose}> {searchQuery && totalCount > 0 && (
<X size={20} /> <span className="so-count-badge">
{totalCount} result{totalCount !== 1 ? "s" : ""}
</span>
)}
<button className="so-close-btn" onClick={onClose}>
<X size={13} color="#9ca3af" />
</button> </button>
</div> </div>
{/* Results */} {/* Results */}
<div className="mt-6 max-h-96 overflow-y-auto space-y-6"> <div className="so-results">
{/* {!searchQuery && ( {/* ── Empty query: recent + quick nav ── */}
<p className="font-satoshi text-gray-500"> {!searchQuery && (
Start typing to search... <>
</p> {recent.length > 0 && (
)} */} <div>
<p className="so-section-label">
<Clock size={10} /> Recent
</p>
{recent.map((item, i) => {
const navMeta = NAV_MAP[item.route!];
const Icon = navMeta?.icon ?? BookOpen;
const color = navMeta?.color ?? "#a855f7";
const bg = navMeta?.bg ?? "#fdf4ff";
return (
<div
key={i}
className="so-item"
onClick={() => handleSelect(item)}
>
<div
className="so-item-icon"
style={{ background: bg }}
>
<Icon size={16} color={color} />
</div>
<div className="so-item-body">
<p className="so-item-title">{item.title}</p>
{item.description && (
<p className="so-item-desc">{item.description}</p>
)}
</div>
<ArrowRight size={15} className="so-item-arrow" />
</div>
);
})}
</div>
)}
{searchQuery.length === 0 ? ( <div>
<p className="text-gray-400 font-satoshi"> <p className="so-section-label"> Quick nav</p>
Start typing to search... <div
</p> className="so-quick-wrap"
) : Object.keys(groupedResults).length === 0 ? ( style={{ marginTop: "0.5rem" }}
<p className="text-gray-400 font-satoshi">No results found.</p> >
) : ( {NAV_ITEMS.map((item, i) => (
Object.entries(groupedResults).map(([group, items]) => ( <button
<div key={group}> key={i}
<p className="text-xs uppercase tracking-wider text-gray-400 font-satoshi mb-3"> className="so-quick-chip"
{group} onClick={() => handleSelect(item)}
</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"> <item.icon size={13} color={item.color} />
{highlightText(item.title, searchQuery)} {item.title}
</p> </button>
{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> </div>
))
{sheets.length > 0 && (
<div>
<p className="so-section-label">
<Flame size={10} /> In progress
</p>
{sheets
.filter((s) => s.user_status === "IN_PROGRESS")
.slice(0, 3)
.map((sheet) => {
const item: SearchItem = {
type: "sheet",
title: sheet.title,
description: sheet.description,
route: `/student/practice/${sheet.id}`,
group: "In Progress",
status: sheet.user_status,
};
return (
<div
key={sheet.id}
className="so-item"
onClick={() => handleSelect(item)}
>
<div
className="so-item-icon"
style={{ background: "#f3e8ff" }}
>
<BookOpen size={16} color="#a855f7" />
</div>
<div className="so-item-body">
<p className="so-item-title">{sheet.title}</p>
{sheet.description && (
<p className="so-item-desc">
{sheet.description}
</p>
)}
</div>
<span
className="so-status-chip"
style={{
background: "#f3e8ff",
color: "#9333ea",
}}
>
In Progress
</span>
</div>
);
})}
</div>
)}
</>
)} )}
{/* ── No results ── */}
{searchQuery && totalCount === 0 && (
<div className="so-empty">
<span className="so-empty-emoji">🔍</span>
<p className="so-empty-text">No results for "{searchQuery}"</p>
<p className="so-empty-sub">
Try searching for a topic, sheet title, or page name
</p>
</div>
)}
{/* ── Results grouped ── */}
{searchQuery &&
totalCount > 0 &&
Object.entries(groupedResults).map(([group, items]) => (
<div key={group}>
<p className="so-section-label">{group}</p>
{items.map((item, index) => {
const globalIdx = flatResults.indexOf(item);
const isFocused = globalIdx === focused;
const navMeta = NAV_MAP[item.route!];
const Icon = navMeta?.icon ?? BookOpen;
const iconColor = navMeta?.color ?? "#a855f7";
const iconBg = navMeta?.bg ?? "#fdf4ff";
const statusMeta = item.status
? STATUS_META[item.status as keyof typeof STATUS_META]
: null;
return (
<motion.div
key={index}
className="so-item"
style={{
background: isFocused ? "white" : undefined,
borderColor: isFocused ? "#e9d5ff" : undefined,
boxShadow: isFocused
? "0 4px 12px rgba(0,0,0,0.06)"
: undefined,
}}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
onClick={() => handleSelect(item)}
>
<div
className="so-item-icon"
style={{ background: iconBg }}
>
{item.type === "sheet" ? (
<span style={{ fontSize: "1rem" }}>
{statusMeta?.icon ?? "📋"}
</span>
) : (
<Icon size={16} color={iconColor} />
)}
</div>
<div className="so-item-body">
<p className="so-item-title">
{highlightText(item.title, searchQuery)}
</p>
{item.description && (
<p className="so-item-desc">
{highlightText(item.description, searchQuery)}
</p>
)}
</div>
{statusMeta && (
<span
className="so-status-chip"
style={{
background: statusMeta.bg,
color: statusMeta.color,
}}
>
{statusMeta.label}
</span>
)}
<ArrowRight size={15} className="so-item-arrow" />
</motion.div>
);
})}
</div>
))}
</div>
{/* Keyboard hints */}
<div className="so-kbd-row">
<div className="so-kbd-hint">
<kbd className="so-kbd"></kbd> Navigate
</div>
<div className="so-kbd-hint">
<kbd className="so-kbd"></kbd> Open
</div>
<div className="so-kbd-hint">
<kbd className="so-kbd">Esc</kbd> Close
</div>
</div> </div>
</motion.div> </motion.div>
</motion.div> </motion.div>

View File

@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
import { useAuthStore } from "../../stores/authStore"; import { useAuthStore } from "../../stores/authStore";
import { CheckCircle, Flame, Gauge, Play, Search } from "lucide-react"; import { CheckCircle, Flame, Gauge, Play, Search } from "lucide-react";
import { api } from "../../utils/api"; import { api } from "../../utils/api";
import { Badge } from "../../components/ui/badge";
import type { PracticeSheet } from "../../types/sheet"; import type { PracticeSheet } from "../../types/sheet";
import { formatStatus } from "../../lib/utils"; import { formatStatus } from "../../lib/utils";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@ -13,7 +12,6 @@ import {
AvatarFallback, AvatarFallback,
AvatarImage, AvatarImage,
} from "../../components/ui/avatar"; } from "../../components/ui/avatar";
import { useExamConfigStore } from "../../stores/useExamConfigStore";
import { import {
Drawer, Drawer,
DrawerContent, DrawerContent,
@ -334,7 +332,6 @@ const PAGE_SIZE = 2;
export const Home = () => { export const Home = () => {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const navigate = useNavigate(); const navigate = useNavigate();
const userXp = useExamConfigStore.getState().userXp;
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]); const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]); const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
@ -450,9 +447,9 @@ export const Home = () => {
{user?.name?.slice(0, 1)} {user?.name?.slice(0, 1)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div> <div className="space-y-1">
<p className="home-user-name"> <p className="home-user-name">
{greeting}, {user?.name?.split(" ")[0] || "Student"} 👋 {greeting}, {user?.name?.split(" ")[0] || "Student"}
</p> </p>
<p className="home-user-role"> <p className="home-user-role">
{user?.role === "STUDENT" {user?.role === "STUDENT"

View File

@ -360,6 +360,8 @@ const GLOBAL_STYLES = `
/* ── Retry Banner ── */ /* ── Retry Banner ── */
.t-retry-banner { .t-retry-banner {
background: linear-gradient(90deg, ${COLORS.accent}, #ea580c); background: linear-gradient(90deg, ${COLORS.accent}, #ea580c);
position: fixed;
width: 100%;
color: white; color: white;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
font-weight: 700; font-weight: 700;