Files
edbridge-scholars/src/components/SearchOverlay.tsx

683 lines
22 KiB
TypeScript

import { motion, AnimatePresence } from "framer-motion";
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";
import { formatGroupTitle } from "../lib/utils";
interface Props {
sheets: PracticeSheet[];
onClose: () => void;
searchQuery: string;
setSearchQuery: (value: string) => void;
}
// ─── Nav items ────────────────────────────────────────────────────────────────
const NAV_ITEMS: (SearchItem & {
icon: React.ElementType;
color: string;
bg: string;
})[] = [
{
type: "route",
title: "Hard Test 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 your weak spots",
route: "/student/practice/targeted-practice",
group: "Pages",
icon: Target,
color: "#ef4444",
bg: "#fff5f5",
},
{
type: "route",
title: "Drills",
description: "Train speed and accuracy",
route: "/student/practice/drills",
group: "Pages",
icon: Zap,
color: "#0891b2",
bg: "#ecfeff",
},
{
type: "route",
title: "Leaderboard",
description: "See how you rank against others",
route: "/student/rewards",
group: "Pages",
icon: Trophy,
color: "#f97316",
bg: "#fff7ed",
},
{
type: "route",
title: "Practice",
description: "Browse all practice modes",
route: "/student/practice",
group: "Pages",
icon: BookOpen,
color: "#a855f7",
bg: "#fdf4ff",
},
{
type: "route",
title: "Lessons",
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 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 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 = ({
sheets,
onClose,
searchQuery,
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" as const,
id: sheet.id,
title: sheet.title,
description: sheet.description ?? "Practice sheet",
route: `/student/practice/${sheet.id}`,
group: formatGroupTitle(sheet.user_status),
status: sheet.user_status,
}));
return [...NAV_ITEMS, ...sheetItems];
}, [sheets]);
// Filtered + grouped results
const groupedResults = useMemo(() => {
if (!searchQuery.trim()) return {};
const q = searchQuery.toLowerCase();
const filtered = searchItems.filter(
(item) =>
item.title?.toLowerCase().includes(q) ||
item.description?.toLowerCase().includes(q),
);
return filtered.reduce<Record<string, SearchItem[]>>((acc, 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="so-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<style>{STYLES}</style>
<motion.div
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()}
>
{/* 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 sheets, pages, topics..."
/>
{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="so-results">
{/* ── Empty query: recent + quick nav ── */}
{!searchQuery && (
<>
{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>
)}
<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>
);
};