refactor(search): refactor search ui for overall style coherence
This commit is contained in:
@ -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 ? (
|
||||
<motion.span
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className="bg-purple-200 text-purple-900 px-1 rounded-md"
|
||||
>
|
||||
{part}
|
||||
</motion.span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
});
|
||||
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,
|
||||
@ -99,131 +350,330 @@ 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...
|
||||
</p>
|
||||
)} */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{searchQuery.length === 0 ? (
|
||||
<p className="text-gray-400 font-satoshi">
|
||||
Start typing to search...
|
||||
</p>
|
||||
) : Object.keys(groupedResults).length === 0 ? (
|
||||
<p className="text-gray-400 font-satoshi">No results found.</p>
|
||||
) : (
|
||||
Object.entries(groupedResults).map(([group, items]) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs uppercase tracking-wider text-gray-400 font-satoshi mb-3">
|
||||
{group}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate(item.route!);
|
||||
}}
|
||||
className="p-4 rounded-2xl hover:bg-gray-100 cursor-pointer transition"
|
||||
<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)}
|
||||
>
|
||||
<p className="font-satoshi-medium">
|
||||
{highlightText(item.title, searchQuery)}
|
||||
</p>
|
||||
|
||||
{item.description && (
|
||||
<p className="text-sm text-gray-500">
|
||||
{highlightText(item.description, searchQuery)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-purple-500 mt-1">
|
||||
{item.type === "route" ? "" : "Practice Sheet"}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user