feat(home): add spotlight search functionality
This commit is contained in:
232
src/components/SearchOverlay.tsx
Normal file
232
src/components/SearchOverlay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -98,3 +98,16 @@ export const slideVariants = {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@ -6,14 +6,7 @@ import {
|
||||
TabsContent,
|
||||
} from "../../components/ui/tabs";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import {
|
||||
CheckCircle,
|
||||
DecimalsArrowRight,
|
||||
DraftingCompass,
|
||||
List,
|
||||
Search,
|
||||
SquarePen,
|
||||
} from "lucide-react";
|
||||
import { CheckCircle, Search } from "lucide-react";
|
||||
import { api } from "../../utils/api";
|
||||
import {
|
||||
Card,
|
||||
@ -28,8 +21,7 @@ import { Button } from "../../components/ui/button";
|
||||
import type { PracticeSheet } from "../../types/sheet";
|
||||
import { formatStatus } from "../../lib/utils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Progress } from "../../components/ui/progress";
|
||||
import { Field, FieldLabel } from "../../components/ui/field";
|
||||
import { SearchOverlay } from "../../components/SearchOverlay";
|
||||
|
||||
export const Home = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
@ -40,6 +32,9 @@ export const Home = () => {
|
||||
const [inProgressSheets, setInProgressSheets] = useState<PracticeSheet[]>([]);
|
||||
const [completedSheets, setCompletedSheets] = useState<PracticeSheet[]>([]);
|
||||
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const sortPracticeSheets = (sheets: PracticeSheet[]) => {
|
||||
const notStarted = sheets.filter(
|
||||
@ -75,7 +70,6 @@ export const Home = () => {
|
||||
}
|
||||
const sheets = await api.getPracticeSheets(token, 1, 10);
|
||||
setPracticeSheets(sheets.data);
|
||||
console.log("All Practice Sheets: ", sheets.data);
|
||||
sortPracticeSheets(sheets.data);
|
||||
} catch (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">
|
||||
Welcome, {user?.name || "Student"}
|
||||
</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">
|
||||
What are you looking for?
|
||||
</h1>
|
||||
<section className="relative w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
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"
|
||||
onFocus={() => setIsSearchOpen(true)}
|
||||
placeholder="Search practice sheets..."
|
||||
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">
|
||||
<Search size={22} color="gray" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
||||
Pick up where you left off
|
||||
@ -402,6 +351,17 @@ export const Home = () => {
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{isSearchOpen && (
|
||||
<SearchOverlay
|
||||
sheets={practiceSheets}
|
||||
onClose={() => {
|
||||
setIsSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
16
src/types/search.ts
Normal file
16
src/types/search.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user