From cb48f93d6b01d7aebb63c3dcea16fa249c1ba631 Mon Sep 17 00:00:00 2001 From: tejask011 Date: Wed, 27 May 2026 10:11:21 +0530 Subject: [PATCH] feat(ux): improve ui feedback, search overlay, and error handling across application under GSSoC 2026 --- app/components/SearchModal.tsx | 324 +++++++++++++++++++++++++++++++++ app/components/Skeleton.tsx | 40 ++++ app/components/Toast.tsx | 107 +++++++++++ app/components/navbar.tsx | 282 +++++++++++++++++----------- app/components/subjects.tsx | 18 +- app/error.tsx | 75 ++++++++ app/globals.css | 52 ++++++ app/layout.tsx | 103 ++++++----- app/quiz/[slug]/QuizClient.tsx | 23 ++- lib/searchIndex.ts | 294 ++++++++++++++++++++++++++++++ 10 files changed, 1153 insertions(+), 165 deletions(-) create mode 100644 app/components/SearchModal.tsx create mode 100644 app/components/Skeleton.tsx create mode 100644 app/components/Toast.tsx create mode 100644 app/error.tsx create mode 100644 lib/searchIndex.ts 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 cc69e67..73844ed 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,118 +14,183 @@ 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 ( -
+ + {/* Desktop Menu */} +
+
    +
  • + + HOME + +
  • +
  • + + SUBJECTS + +
  • +
  • + + CONTRIBUTE + +
  • +
  • + + SPONSOR + +
  • +
  • + + QUIZ + +
  • +
+
+ + {/* Mobile controls */} +
+ {/* Mobile Search Button */} + + + {/* Mobile Hamburger */} + +
+ + {/* Mobile Menu */} +
- - {/* Mobile Menu */} -
-
    -
  • - setMenuOpen(false)} className="hover:text-[#d2b48c] transition-colors duration-200 cursor-pointer"> - HOME - -
  • -
  • - setMenuOpen(false)} className="hover:text-[#d2b48c] transition-colors duration-200 cursor-pointer"> - SUBJECTS - -
  • -
  • - setMenuOpen(false)} className="hover:text-[#d2b48c] transition-colors duration-200 cursor-pointer"> - CONTRIBUTE - -
  • -
  • - setMenuOpen(false)} className="hover:text-[#d2b48c] transition-colors duration-200 cursor-pointer"> - SPONSOR - -
  • -
  • +
      +
    • + setMenuOpen(false)} className="hover:text-[#d2b48c] transition-colors duration-200 cursor-pointer"> + HOME + +
    • +
    • + setMenuOpen(false)} className="hover:text-[#d2b48c] transition-colors duration-200 cursor-pointer"> + SUBJECTS + +
    • +
    • + setMenuOpen(false)} className="hover:text-[#d2b48c] transition-colors duration-200 cursor-pointer"> + CONTRIBUTE + +
    • +
    • + setMenuOpen(false)} className="hover:text-[#d2b48c] transition-colors duration-200 cursor-pointer"> + SPONSOR + +
    • +
    • setMenuOpen(false)} className="hover:text-[#d2b48c] transition-colors duration-200 cursor-pointer"> - QUIZ + QUIZ
    • -
    -
- + +
+ + + {/* Global Search Modal */} + setSearchOpen(false)} /> + ); } \ No newline at end of file diff --git a/app/components/subjects.tsx b/app/components/subjects.tsx index ccd0dd3..0dbb3f2 100644 --- a/app/components/subjects.tsx +++ b/app/components/subjects.tsx @@ -1,5 +1,6 @@ "use client"; import Link from "next/link"; +import { useToast } from "./Toast"; const subjects = { "Semester-1": [ @@ -128,6 +129,8 @@ const subjectCodes: Record = { const available = ["ep", "c", "em1", "em2", "oops", "dsc", "coa", "os", "ml", "dops", "cd", "cle"]; export default function SubjectsSection() { + const { info } = useToast(); + return (

{subj} ) : ( -
info(`"${subj}" notes are currently being drafted! Stay tuned for updates. ๐Ÿ“š`)} + className={`${baseClass} opacity-60 hover:opacity-85 hover:scale-[99%] cursor-pointer`} > {subj} - - Coming Soon + + Soon -
+ ); })} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..3a9cd9b --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,75 @@ +"use client"; + +import React, { useEffect } from "react"; +import Link from "next/link"; +import { AlertOctagon, RotateCcw, Home, Info } from "lucide-react"; +import Navbar from "./components/navbar"; + +interface ErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function GlobalError({ error, reset }: ErrorProps) { + useEffect(() => { + // Log the error to an analytics or error tracking service + console.error("Runtime Error caught by boundary:", error); + }, [error]); + + return ( +
+ + +
+
+ +
+ + + System Interrupted + + +

+ Something went offline +

+ +

+ An unexpected error occurred while loading this page. Let's try reloading the section or going back to safety. +

+ + {/* Technical Error Details Drawer */} +
+ + + Diagnostic Details + +
+ {error.message || "Unknown error occurred"} + {error.digest &&
Digest: {error.digest}
} +
+
+ +
+ + + + + Return to Home + +
+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index 495501d..30a115b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -231,4 +231,56 @@ html::-webkit-scrollbar-thumb:hover { background: #a3844e; } +/* openCSE Custom UI Keyframes & Animations */ +@keyframes shimmer { + 100% { + transform: translateX(100%); + } +} + +@keyframes slide-in { + from { + transform: translateX(100%) translateY(0); + opacity: 0; + } + to { + transform: translateX(0) translateY(0); + opacity: 1; + } +} +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes scale-up { + from { + transform: scale(0.97); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +.animate-shimmer { + animation: shimmer 2s infinite; +} + +.animate-slide-in { + animation: slide-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.animate-fade-in { + animation: fade-in 0.2s ease-out forwards; +} + +.animate-scale-up { + animation: scale-up 0.25s cubic-bezier(0.34, 1.3, 0.64, 1) forwards; +} diff --git a/app/layout.tsx b/app/layout.tsx index 9104a0e..f310bfd 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,50 +1,53 @@ -// app/layout.tsx -import type { Metadata, Viewport } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; - -import Footer from "./components/footer"; -import ProgressBar from "./components/ProgressBar"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - -export const metadata: Metadata = { - title: "openCSE", - description: "Free and Open Documentations for CSE subjects", -}; - -export const viewport: Viewport = { - width: "device-width", - initialScale: 0, - maximumScale: 5, - userScalable: true, -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - - -
- {children} -
-