diff --git a/app/components/SearchModal.tsx b/app/components/SearchModal.tsx new file mode 100644 index 0000000..df8c66a --- /dev/null +++ b/app/components/SearchModal.tsx @@ -0,0 +1,324 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { Search, X, BookOpen, FileText, HelpCircle, Keyboard, CornerDownLeft } from "lucide-react"; +import { searchIndex, SearchItem } from "@/lib/searchIndex"; +import { useToast } from "./Toast"; +import Skeleton from "./Skeleton"; + +interface SearchModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function SearchModal({ isOpen, onClose }: SearchModalProps) { + const router = useRouter(); + const { success, info } = useToast(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + + const modalRef = useRef(null); + const inputRef = useRef(null); + const resultsContainerRef = useRef(null); + + // Auto-focus input when modal opens + useEffect(() => { + if (isOpen) { + setTimeout(() => inputRef.current?.focus(), 100); + setSelectedIndex(0); + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [isOpen]); + + // Handle Search input with simulated loading skeleton + useEffect(() => { + if (!query.trim()) { + setResults([]); + setIsLoading(false); + return; + } + + setIsLoading(true); + const delayDebounce = setTimeout(() => { + const filtered = searchIndex.filter((item) => { + const titleMatch = item.title.toLowerCase().includes(query.toLowerCase()); + const descMatch = item.description?.toLowerCase().includes(query.toLowerCase()) ?? false; + const subMatch = item.subjectName.toLowerCase().includes(query.toLowerCase()); + const tagMatch = item.tags?.some(tag => tag.toLowerCase().includes(query.toLowerCase())) ?? false; + return titleMatch || descMatch || subMatch || tagMatch; + }); + + setResults(filtered); + setIsLoading(false); + setSelectedIndex(0); + }, 400); // 400ms loading latency to show beautiful skeleton loaders + + return () => clearTimeout(delayDebounce); + }, [query]); + + // Handle outside clicks + useEffect(() => { + const handleOutsideClick = (e: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleOutsideClick); + } + return () => { + document.removeEventListener("mousedown", handleOutsideClick); + }; + }, [isOpen, onClose]); + + // Keyboard navigation inside search results + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) return; + + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => (results.length > 0 ? (prev + 1) % results.length : 0)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => (results.length > 0 ? (prev - 1 + results.length) % results.length : 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (results[selectedIndex]) { + handleSelectResult(results[selectedIndex]); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, results, selectedIndex]); + + // Scroll active item into view + useEffect(() => { + if (resultsContainerRef.current) { + const activeEl = resultsContainerRef.current.querySelector(".search-item-active"); + if (activeEl) { + activeEl.scrollIntoView({ block: "nearest" }); + } + } + }, [selectedIndex]); + + const handleSelectResult = (item: SearchItem) => { + success(`Opening: ${item.title}`); + router.push(item.path); + onClose(); + }; + + const handleQuickTagClick = (tag: string) => { + setQuery(tag); + inputRef.current?.focus(); + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Search header */} +
+ + setQuery(e.target.value)} + className="flex-1 bg-transparent text-[#FAE8D7] placeholder-[#FAE8D7]/40 text-lg border-none outline-none focus:ring-0 focus:outline-none" + /> + {query && ( + + )} +
+ esc +
+
+ + {/* Search content / results */} +
+ {isLoading ? ( +
+
+ Searching documentation... +
+ + + +
+ ) : query && results.length > 0 ? ( +
+
+ {results.length} results matching “{query}” + + Navigate with โ†‘โ†“ & Enter + +
+ + {results.map((item, index) => { + const isActive = index === selectedIndex; + const isSubject = item.type === "subject"; + const isChapter = item.type === "chapter"; + const isQuiz = item.type === "quiz"; + + return ( + + ); + })} +
+ ) : query ? ( + /* Premium Empty State Component */ +
+
+ +
+

No notes or quizzes found

+

+ We couldn't find anything matching “{query}”. Try another search or explore our subjects below. +

+ +
+
+ Popular topics +
+
+ {["C Programming", "Pointers", "Java", "Linked List", "Compiler", "Machine Learning", "Quiz"].map((pop) => ( + + ))} +
+
+
+ ) : ( + /* Default Search State (Intro tips) */ +
+
+
+ Search Tips & Hacks +
+
    +
  • + 1. + Type key concepts like pointers, recursion, lexical analyzer, or docker. +
  • +
  • + 2. + Directly search for quiz subjects like java quiz or ml quiz to practice. +
  • +
  • + 3. + Filter down dynamically in real-time by semesters or subject names. +
  • +
+
+ +
+
+ Quick search shortcut: + / +
+
+ Press Esc to exit + Made for openCSE ๐Ÿš€ +
+
+
+ )} +
+
+
+ ); +} diff --git a/app/components/Skeleton.tsx b/app/components/Skeleton.tsx new file mode 100644 index 0000000..fcd6b22 --- /dev/null +++ b/app/components/Skeleton.tsx @@ -0,0 +1,40 @@ +"use client"; + +import React from "react"; + +interface SkeletonProps { + className?: string; + variant?: "text" | "rectangular" | "circular" | "card" | "list-item"; + height?: string | number; + width?: string | number; +} + +export default function Skeleton({ + className = "", + variant = "rectangular", + height, + width, +}: SkeletonProps) { + // Styles tailored to the warm wood/leather/gold palette + const baseClass = "relative overflow-hidden bg-[#2d1908] rounded-lg animate-pulse before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-[#42270f]/30 before:to-transparent"; + + const variantClasses = { + text: "h-4 w-3/4 rounded-md my-2", + rectangular: "w-full rounded-lg", + circular: "rounded-full aspect-square", + card: "w-full h-48 rounded-2xl p-6 flex flex-col justify-between", + "list-item": "w-full h-16 rounded-xl flex items-center px-4 gap-4", + }; + + const style: React.CSSProperties = { + height: height !== undefined ? height : undefined, + width: width !== undefined ? width : undefined, + }; + + return ( +
+ ); +} diff --git a/app/components/Toast.tsx b/app/components/Toast.tsx new file mode 100644 index 0000000..d06e81f --- /dev/null +++ b/app/components/Toast.tsx @@ -0,0 +1,107 @@ +"use client"; + +import React, { createContext, useContext, useState, useCallback } from "react"; +import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react"; + +export type ToastType = "success" | "error" | "info" | "warning"; + +export interface Toast { + id: string; + message: string; + type: ToastType; + duration?: number; +} + +interface ToastContextType { + toast: (message: string, type?: ToastType, duration?: number) => void; + success: (message: string, duration?: number) => void; + error: (message: string, duration?: number) => void; + info: (message: string, duration?: number) => void; + warning: (message: string, duration?: number) => void; +} + +const ToastContext = createContext(undefined); + +export function useToast() { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToast must be used within a ToastProvider"); + } + return context; +} + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const toast = useCallback( + (message: string, type: ToastType = "info", duration = 4000) => { + const id = Math.random().toString(36).substring(2, 9); + const newToast: Toast = { id, message, type, duration }; + + setToasts((prev) => [...prev, newToast]); + + setTimeout(() => { + removeToast(id); + }, duration); + }, + [removeToast] + ); + + const success = useCallback((msg: string, dur?: number) => toast(msg, "success", dur), [toast]); + const error = useCallback((msg: string, dur?: number) => toast(msg, "error", dur), [toast]); + const info = useCallback((msg: string, dur?: number) => toast(msg, "info", dur), [toast]); + const warning = useCallback((msg: string, dur?: number) => toast(msg, "warning", dur), [toast]); + + return ( + + {children} + + {/* Toast container */} +
+ {toasts.map((t) => ( + removeToast(t.id)} /> + ))} +
+
+ ); +} + +function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { + const iconMap = { + success: , + error: , + info: , + warning: , + }; + + const borderMap = { + success: "border-l-4 border-l-green-500 border border-green-950/20", + error: "border-l-4 border-l-red-500 border border-red-950/20", + info: "border-l-4 border-l-[#C7A669] border border-[#C7A669]/20", + warning: "border-l-4 border-l-amber-500 border border-amber-950/20", + }; + + return ( +
+
{iconMap[toast.type]}
+
{toast.message}
+ +
+ ); +} diff --git a/app/components/navbar.tsx b/app/components/navbar.tsx index 988aeb5..7fc9ba0 100644 --- a/app/components/navbar.tsx +++ b/app/components/navbar.tsx @@ -1,7 +1,10 @@ "use client"; + import Link from "next/link"; import { Road_Rage } from "next/font/google"; -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { Search } from "lucide-react"; +import SearchModal from "./SearchModal"; const roadRage = Road_Rage({ variable: "--font-road-rage", @@ -11,8 +14,184 @@ const roadRage = Road_Rage({ export default function Navbar() { const [menuOpen, setMenuOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Prevent opening when user is typing in an input/textarea + if ( + e.key === "/" && + document.activeElement?.tagName !== "INPUT" && + document.activeElement?.tagName !== "TEXTAREA" + ) { + e.preventDefault(); + setSearchOpen(true); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); return ( + <> + + + {/* Global Search Modal */} + setSearchOpen(false)} /> +