feat(home): add spotlight search functionality

This commit is contained in:
shafin-r
2026-02-14 03:24:22 +06:00
parent 7f82e640e0
commit 96eb2c13b0
4 changed files with 282 additions and 61 deletions

View File

@ -0,0 +1,232 @@
import { motion, AnimatePresence } from "framer-motion";
import { useEffect, useMemo } from "react";
import { Search, X } 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;
}
const navigationItems: SearchItem[] = [
{
type: "route",
title: "Hard Test Modules",
description: "Access advanced SAT modules",
route: "/student/hard-test-modules",
group: "Pages",
},
{
type: "route",
title: "Targeted Practice",
description: "Focus on what matters",
route: "/student/practice/targeted-practice",
group: "Pages",
},
{
type: "route",
title: "Drills",
description: "Train speed and accuracy",
route: "/student/practice/drills",
group: "Pages",
},
{
type: "route",
title: "Leaderboard",
description: "View student rankings",
route: "/student/rewards",
group: "Pages",
},
{
type: "route",
title: "Practice",
description: "See how you can practice",
route: "/student/practice",
group: "Pages",
},
{
type: "route",
title: "Lessons",
description: "Watch detailed lessons on SAT techniques",
route: "/student/lessons",
group: "Pages",
},
{
type: "route",
title: "Profile",
description: "View your profile",
route: "/student/profile",
group: "Pages",
},
];
const highlightText = (text: string, query: string) => {
if (!query.trim()) return text;
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
);
});
};
export const SearchOverlay = ({
sheets,
onClose,
searchQuery,
setSearchQuery,
}: Props) => {
const navigate = useNavigate();
const searchItems = useMemo<SearchItem[]>(() => {
const sheetItems = sheets.map((sheet) => ({
type: "sheet",
id: sheet.id,
title: sheet.title,
description: sheet.description,
route: `/student/practice/${sheet.id}`,
group: formatGroupTitle(sheet.user_status), // 👈 reuse your grouping
}));
return [...navigationItems, ...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]);
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);
});
return filtered.reduce<Record<string, SearchItem[]>>((acc, item) => {
if (!acc[item.group]) {
acc[item.group] = [];
}
acc[item.group].push(item);
return acc;
}, {});
}, [searchQuery, searchItems]);
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"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
{/* Search Box */}
<motion.div
initial={{ y: -40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -40, opacity: 0 }}
transition={{ type: "spring", stiffness: 300 }}
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
autoFocus
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="flex-1 outline-none font-satoshi text-lg"
/>
<button onClick={onClose}>
<X size={20} />
</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>
)} */}
{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"
>
<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>
))}
</div>
</div>
))
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
};

View File

@ -98,3 +98,16 @@ export const slideVariants = {
transition: { duration: 0.25 }, transition: { duration: 0.25 },
}), }),
}; };
export const formatGroupTitle = (status: string) => {
switch (status) {
case "IN_PROGRESS":
return "In Progress";
case "NOT_STARTED":
return "Not Started";
case "COMPLETED":
return "Completed";
default:
return status;
}
};

View File

@ -6,14 +6,7 @@ import {
TabsContent, TabsContent,
} from "../../components/ui/tabs"; } from "../../components/ui/tabs";
import { useAuthStore } from "../../stores/authStore"; import { useAuthStore } from "../../stores/authStore";
import { import { CheckCircle, Search } from "lucide-react";
CheckCircle,
DecimalsArrowRight,
DraftingCompass,
List,
Search,
SquarePen,
} from "lucide-react";
import { api } from "../../utils/api"; import { api } from "../../utils/api";
import { import {
Card, Card,
@ -28,8 +21,7 @@ import { Button } from "../../components/ui/button";
import type { PracticeSheet } from "../../types/sheet"; import type { PracticeSheet } from "../../types/sheet";
import { formatStatus } from "../../lib/utils"; import { formatStatus } from "../../lib/utils";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Progress } from "../../components/ui/progress"; import { SearchOverlay } from "../../components/SearchOverlay";
import { Field, FieldLabel } from "../../components/ui/field";
export const Home = () => { export const Home = () => {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
@ -40,6 +32,9 @@ export const Home = () => {
const [inProgressSheets, setInProgressSheets] = useState<PracticeSheet[]>([]); const [inProgressSheets, setInProgressSheets] = useState<PracticeSheet[]>([]);
const [completedSheets, setCompletedSheets] = useState<PracticeSheet[]>([]); const [completedSheets, setCompletedSheets] = useState<PracticeSheet[]>([]);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
const sortPracticeSheets = (sheets: PracticeSheet[]) => { const sortPracticeSheets = (sheets: PracticeSheet[]) => {
const notStarted = sheets.filter( const notStarted = sheets.filter(
@ -75,7 +70,6 @@ export const Home = () => {
} }
const sheets = await api.getPracticeSheets(token, 1, 10); const sheets = await api.getPracticeSheets(token, 1, 10);
setPracticeSheets(sheets.data); setPracticeSheets(sheets.data);
console.log("All Practice Sheets: ", sheets.data);
sortPracticeSheets(sheets.data); sortPracticeSheets(sheets.data);
} catch (error) { } catch (error) {
console.error("Error fetching practice sheets:", error); console.error("Error fetching practice sheets:", error);
@ -94,66 +88,21 @@ export const Home = () => {
<h1 className="text-4xl font-satoshi-bold tracking-tight text-gray-800 text-center"> <h1 className="text-4xl font-satoshi-bold tracking-tight text-gray-800 text-center">
Welcome, {user?.name || "Student"} Welcome, {user?.name || "Student"}
</h1> </h1>
{/* <section className="border rounded-3xl p-5 space-y-4">
<p className="font-satoshi">
Your predictive SAT score is low. Take a practice test to increase
your scores now!
</p>
</section> */}
{/* <Card className="relative bg-linear-to-br from-red-600 to-red-700 rounded-4xl">
<div className="space-y-4">
<CardHeader className="">
<CardTitle className="font-satoshi-bold tracking-tight text-3xl text-white">
Your score is low!
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Field className="w-full">
<FieldLabel htmlFor="progress-upload">
<span className="font-satoshi text-white">Score</span>
<span className="ml-auto font-satoshi text-white">
854/1600
</span>
</FieldLabel>
<Progress value={55} id="progress-upload" max={100} />
</Field>
<p className="font-satoshi text-white">
Taking more practice tests can increase your score today!
</p>
</CardContent>
<CardFooter className="flex justify-between">
<Button
onClick={() => navigate("/student/analytics")}
className="bg-transparent border-2 py-3 px-5 text-md font-satoshi rounded-full"
>
<List />
View
</Button>
<Button className="bg-gray-50 py-3 px-5 text-md font-satoshi text-black rounded-full">
<SquarePen />
Take a practice test
</Button>
</CardFooter>
</div>
<div className="overflow-hidden opacity-30 -rotate-45 absolute -top-2 -right-30 ">
<DecimalsArrowRight size={380} color="white" />
</div>
</Card> */}
<h1 className="font-satoshi-bold text-2xl tracking-tight"> <h1 className="font-satoshi-bold text-2xl tracking-tight">
What are you looking for? What are you looking for?
</h1> </h1>
<section className="relative w-full"> <section className="relative w-full">
<input <input
type="text" onFocus={() => setIsSearchOpen(true)}
placeholder="Search..." placeholder="Search practice sheets..."
className="font-satoshi w-full pl-10 pr-4 py-3 border border-gray-300 rounded-2xl shadow-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" readOnly
className="font-satoshi w-full pl-10 pr-4 py-3 border border-gray-300 rounded-2xl shadow-sm cursor-pointer"
/> />
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"> <div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Search size={22} color="gray" /> <Search size={22} color="gray" />
</div> </div>
</section> </section>
<section className="space-y-4"> <section className="space-y-4">
<h1 className="font-satoshi-bold text-2xl tracking-tight"> <h1 className="font-satoshi-bold text-2xl tracking-tight">
Pick up where you left off Pick up where you left off
@ -402,6 +351,17 @@ export const Home = () => {
</div> </div>
</section> </section>
</section> </section>
{isSearchOpen && (
<SearchOverlay
sheets={practiceSheets}
onClose={() => {
setIsSearchOpen(false);
setSearchQuery("");
}}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
)}
</main> </main>
); );
}; };

16
src/types/search.ts Normal file
View File

@ -0,0 +1,16 @@
export type SearchItem =
| {
type: "sheet";
id: string;
title: string;
description?: string;
status?: string;
group: string;
}
| {
type: "route";
title: string;
description?: string;
route: string;
group: string;
};