refactor(search): refactor search ui for overall style coherence
This commit is contained in:
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 NAV_MAP = Object.fromEntries(NAV_ITEMS.map((n) => [n.route, n]));
|
||||
|
||||
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 escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||
|
||||
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, 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"
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
part.toLowerCase() === query.toLowerCase() ? (
|
||||
<mark
|
||||
key={i}
|
||||
style={{
|
||||
background: "#e9d5ff",
|
||||
color: "#6b21a8",
|
||||
borderRadius: 4,
|
||||
padding: "0 2px",
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</motion.span>
|
||||
</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 = ({
|
||||
sheets,
|
||||
onClose,
|
||||
@ -99,132 +350,331 @@ export const SearchOverlay = ({
|
||||
setSearchQuery,
|
||||
}: Props) => {
|
||||
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 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<Record<string, SearchItem[]>>((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 (
|
||||
<AnimatePresence>
|
||||
<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 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Search Box */}
|
||||
<style>{STYLES}</style>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: -40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -40, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300 }}
|
||||
className="so-box"
|
||||
initial={{ y: -24, opacity: 0, scale: 0.97 }}
|
||||
animate={{ y: 0, opacity: 1, scale: 1 }}
|
||||
exit={{ y: -24, opacity: 0, scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 28 }}
|
||||
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 row */}
|
||||
<div className="so-input-row">
|
||||
<Search size={18} color="#9ca3af" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
className="so-input"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="flex-1 outline-none font-satoshi text-lg"
|
||||
placeholder="Search sheets, pages, topics..."
|
||||
/>
|
||||
<button onClick={onClose}>
|
||||
<X size={20} />
|
||||
{searchQuery && totalCount > 0 && (
|
||||
<span className="so-count-badge">
|
||||
{totalCount} result{totalCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
<button className="so-close-btn" onClick={onClose}>
|
||||
<X size={13} color="#9ca3af" />
|
||||
</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...
|
||||
<div className="so-results">
|
||||
{/* ── Empty query: recent + quick nav ── */}
|
||||
{!searchQuery && (
|
||||
<>
|
||||
{recent.length > 0 && (
|
||||
<div>
|
||||
<p className="so-section-label">
|
||||
<Clock size={10} /> Recent
|
||||
</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) => (
|
||||
{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={index}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate(item.route!);
|
||||
}}
|
||||
className="p-4 rounded-2xl hover:bg-gray-100 cursor-pointer transition"
|
||||
key={i}
|
||||
className="so-item"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<p className="font-satoshi-medium">
|
||||
{highlightText(item.title, searchQuery)}
|
||||
</p>
|
||||
|
||||
<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="text-sm text-gray-500">
|
||||
{highlightText(item.description, searchQuery)}
|
||||
</p>
|
||||
<p className="so-item-desc">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight size={15} className="so-item-arrow" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-purple-500 mt-1">
|
||||
{item.type === "route" ? "" : "Practice Sheet"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="so-section-label">⚡ Quick nav</p>
|
||||
<div
|
||||
className="so-quick-wrap"
|
||||
style={{ marginTop: "0.5rem" }}
|
||||
>
|
||||
{NAV_ITEMS.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="so-quick-chip"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<item.icon size={13} color={item.color} />
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
@ -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<PracticeSheet[]>([]);
|
||||
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
|
||||
@ -450,9 +447,9 @@ export const Home = () => {
|
||||
{user?.name?.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="space-y-1">
|
||||
<p className="home-user-name">
|
||||
{greeting}, {user?.name?.split(" ")[0] || "Student"} 👋
|
||||
{greeting}, {user?.name?.split(" ")[0] || "Student"}
|
||||
</p>
|
||||
<p className="home-user-role">
|
||||
{user?.role === "STUDENT"
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user