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 },
|
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,
|
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
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