diff --git a/src/components/GeoGebraGraph.tsx b/src/components/GeoGebraGraph.tsx deleted file mode 100644 index a89fc61..0000000 --- a/src/components/GeoGebraGraph.tsx +++ /dev/null @@ -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(null); - const appRef = useRef(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 ( -
-
-
- ); -} diff --git a/src/components/GraphPlotter.tsx b/src/components/GraphPlotter.tsx deleted file mode 100644 index 56cfa78..0000000 --- a/src/components/GraphPlotter.tsx +++ /dev/null @@ -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(null); - const containerRef = useRef(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(null); - const [activeIntersections, setActiveIntersections] = useState< - Intersection[] - >([]); - - // Pan state - const isPanning = useRef(false); - const lastPointer = useRef({ x: 0, y: 0 }); - const lastPinchDist = useRef(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 ( -
- - - {/* Equation legend */} -
- {equations.map((eq, idx) => ( -
- - - {eq.label ?? `y = ${eq.fn}`} - -
- ))} -
- - {/* Controls */} -
- - - -
- - {/* Intersection tooltip */} - {tooltip && ( -
-
-
- Intersection -
-
- x ={" "} - - {fmt(tooltip.mathX)} - -
-
- y ={" "} - - {fmt(tooltip.mathY)} - -
-
- eq {tooltip.eqA + 1} ∩ eq {tooltip.eqB + 1} -
-
- {/* Arrow */} -
-
-
-
- )} - - {/* Dismiss tooltip on background click hint */} - {tooltip && ( -
- ); -}; diff --git a/src/components/SearchOverlay.tsx b/src/components/SearchOverlay.tsx index cad6429..8b2963c 100644 --- a/src/components/SearchOverlay.tsx +++ b/src/components/SearchOverlay.tsx @@ -1,6 +1,18 @@ import { motion, AnimatePresence } from "framer-motion"; -import { useEffect, useMemo } from "react"; -import { Search, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Search, + X, + BookOpen, + Zap, + Target, + Trophy, + User, + Home, + ArrowRight, + Clock, + Flame, +} from "lucide-react"; import type { PracticeSheet } from "../types/sheet"; import { useNavigate } from "react-router-dom"; import type { SearchItem } from "../types/search"; @@ -13,20 +25,32 @@ interface Props { setSearchQuery: (value: string) => void; } -const navigationItems: SearchItem[] = [ +// ─── Nav items ──────────────────────────────────────────────────────────────── + +const NAV_ITEMS: (SearchItem & { + icon: React.ElementType; + color: string; + bg: string; +})[] = [ { type: "route", title: "Hard Test Modules", - description: "Access advanced SAT modules", + description: "Tackle the hardest SAT questions", route: "/student/hard-test-modules", group: "Pages", + icon: Trophy, + color: "#84cc16", + bg: "#f7ffe4", }, { type: "route", title: "Targeted Practice", - description: "Focus on what matters", + description: "Focus on your weak spots", route: "/student/practice/targeted-practice", group: "Pages", + icon: Target, + color: "#ef4444", + bg: "#fff5f5", }, { type: "route", @@ -34,64 +58,291 @@ const navigationItems: SearchItem[] = [ description: "Train speed and accuracy", route: "/student/practice/drills", group: "Pages", + icon: Zap, + color: "#0891b2", + bg: "#ecfeff", }, { type: "route", title: "Leaderboard", - description: "View student rankings", + description: "See how you rank against others", route: "/student/rewards", group: "Pages", + icon: Trophy, + color: "#f97316", + bg: "#fff7ed", }, { type: "route", title: "Practice", - description: "See how you can practice", + description: "Browse all practice modes", route: "/student/practice", group: "Pages", + icon: BookOpen, + color: "#a855f7", + bg: "#fdf4ff", }, { type: "route", title: "Lessons", - description: "Watch detailed lessons on SAT techniques", + description: "Watch expert SAT technique lessons", route: "/student/lessons", group: "Pages", + icon: BookOpen, + color: "#0891b2", + bg: "#ecfeff", }, { type: "route", title: "Profile", - description: "View your profile", + description: "View your profile and settings", route: "/student/profile", 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) => { - if (!query.trim()) return text; +const NAV_MAP = Object.fromEntries(NAV_ITEMS.map((n) => [n.route, n])); - 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 ? ( - - {part} - - ) : ( - part - ); - }); +const STATUS_META = { + IN_PROGRESS: { + label: "In Progress", + color: "#9333ea", + bg: "#f3e8ff", + icon: "🔄", + }, + COMPLETED: { + label: "Completed", + color: "#16a34a", + bg: "#f0fdf4", + icon: "✅", + }, + NOT_STARTED: { + label: "Not Started", + color: "#6b7280", + bg: "#f3f4f6", + icon: "📋", + }, }; +// ─── 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() ? ( + + {part} + + ) : ( + 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 = ({ sheets, onClose, @@ -99,131 +350,330 @@ export const SearchOverlay = ({ setSearchQuery, }: Props) => { const navigate = useNavigate(); + const inputRef = useRef(null); + const [recent, setRecent] = useState(getRecent); + const [focused, setFocused] = useState(-1); // keyboard nav index + + // Build full search item list const searchItems = useMemo(() => { const sheetItems = sheets.map((sheet) => ({ - type: "sheet", + type: "sheet" as const, id: sheet.id, title: sheet.title, - description: sheet.description, + description: sheet.description ?? "Practice sheet", route: `/student/practice/${sheet.id}`, - group: formatGroupTitle(sheet.user_status), // 👈 reuse your grouping + group: formatGroupTitle(sheet.user_status), + status: sheet.user_status, })); - - return [...navigationItems, ...sheetItems]; + return [...NAV_ITEMS, ...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]); - + // Filtered + grouped results 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); - }); - + const filtered = searchItems.filter( + (item) => + item.title?.toLowerCase().includes(q) || + item.description?.toLowerCase().includes(q), + ); return filtered.reduce>((acc, item) => { - if (!acc[item.group]) { - acc[item.group] = []; - } - acc[item.group].push(item); + (acc[item.group] ??= []).push(item); return acc; }, {}); }, [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 ( - {/* Search Box */} + + e.stopPropagation()} - className="w-full max-w-2xl bg-white rounded-3xl shadow-2xl p-6" > -
- + {/* Input row */} +
+ setSearchQuery(e.target.value)} - placeholder="Search..." - className="flex-1 outline-none font-satoshi text-lg" + placeholder="Search sheets, pages, topics..." /> -
{/* Results */} -
- {/* {!searchQuery && ( -

- Start typing to search... -

- )} */} +
+ {/* ── Empty query: recent + quick nav ── */} + {!searchQuery && ( + <> + {recent.length > 0 && ( +
+

+ Recent +

+ {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 ( +
handleSelect(item)} + > +
+ +
+
+

{item.title}

+ {item.description && ( +

{item.description}

+ )} +
+ +
+ ); + })} +
+ )} - {searchQuery.length === 0 ? ( -

- Start typing to search... -

- ) : Object.keys(groupedResults).length === 0 ? ( -

No results found.

- ) : ( - Object.entries(groupedResults).map(([group, items]) => ( -
-

- {group} -

- -
- {items.map((item, index) => ( -
{ - onClose(); - navigate(item.route!); - }} - className="p-4 rounded-2xl hover:bg-gray-100 cursor-pointer transition" +
+

⚡ Quick nav

+
+ {NAV_ITEMS.map((item, i) => ( +
+ + {item.title} + ))}
- )) + + {sheets.length > 0 && ( +
+

+ In progress +

+ {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 ( +
handleSelect(item)} + > +
+ +
+
+

{sheet.title}

+ {sheet.description && ( +

+ {sheet.description} +

+ )} +
+ + In Progress + +
+ ); + })} +
+ )} + )} + + {/* ── No results ── */} + {searchQuery && totalCount === 0 && ( +
+ 🔍 +

No results for "{searchQuery}"

+

+ Try searching for a topic, sheet title, or page name +

+
+ )} + + {/* ── Results grouped ── */} + {searchQuery && + totalCount > 0 && + Object.entries(groupedResults).map(([group, items]) => ( +
+

{group}

+ {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 ( + handleSelect(item)} + > +
+ {item.type === "sheet" ? ( + + {statusMeta?.icon ?? "📋"} + + ) : ( + + )} +
+
+

+ {highlightText(item.title, searchQuery)} +

+ {item.description && ( +

+ {highlightText(item.description, searchQuery)} +

+ )} +
+ {statusMeta && ( + + {statusMeta.label} + + )} + +
+ ); + })} +
+ ))} +
+ + {/* Keyboard hints */} +
+
+ ↑↓ Navigate +
+
+ Open +
+
+ Esc Close +
diff --git a/src/components/examTimer.tsx b/src/components/examTimer.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/pages/student/Home.tsx b/src/pages/student/Home.tsx index 6f77b40..741db3a 100644 --- a/src/pages/student/Home.tsx +++ b/src/pages/student/Home.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import { useAuthStore } from "../../stores/authStore"; import { CheckCircle, Flame, Gauge, Play, Search } from "lucide-react"; import { api } from "../../utils/api"; -import { Badge } from "../../components/ui/badge"; import type { PracticeSheet } from "../../types/sheet"; import { formatStatus } from "../../lib/utils"; import { useNavigate } from "react-router-dom"; @@ -13,7 +12,6 @@ import { AvatarFallback, AvatarImage, } from "../../components/ui/avatar"; -import { useExamConfigStore } from "../../stores/useExamConfigStore"; import { Drawer, DrawerContent, @@ -334,7 +332,6 @@ const PAGE_SIZE = 2; export const Home = () => { const user = useAuthStore((state) => state.user); const navigate = useNavigate(); - const userXp = useExamConfigStore.getState().userXp; const [practiceSheets, setPracticeSheets] = useState([]); const [notStartedSheets, setNotStartedSheets] = useState([]); @@ -450,9 +447,9 @@ export const Home = () => { {user?.name?.slice(0, 1)} -
+

- {greeting}, {user?.name?.split(" ")[0] || "Student"} 👋 + {greeting}, {user?.name?.split(" ")[0] || "Student"}

{user?.role === "STUDENT" diff --git a/src/pages/student/practice/Test.tsx b/src/pages/student/practice/Test.tsx index 4155194..2ba79f3 100644 --- a/src/pages/student/practice/Test.tsx +++ b/src/pages/student/practice/Test.tsx @@ -360,6 +360,8 @@ const GLOBAL_STYLES = ` /* ── Retry Banner ── */ .t-retry-banner { background: linear-gradient(90deg, ${COLORS.accent}, #ea580c); + position: fixed; + width: 100%; color: white; padding: 0.75rem 1rem; font-weight: 700;