diff --git a/frontend/app/[topic]/TopicLayout.tsx b/frontend/app/[topic]/TopicLayout.tsx index 389c5a3..ed2e962 100644 --- a/frontend/app/[topic]/TopicLayout.tsx +++ b/frontend/app/[topic]/TopicLayout.tsx @@ -2,15 +2,22 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useTheme } from "../../src/context/ThemeContext"; import { useRouter } from "next/navigation"; +import { FaGithub } from "react-icons/fa"; +import toast, { Toaster } from "react-hot-toast"; +import Fuse from "fuse.js"; // <-- Imported Fuse.js import { ArrowLeft, + ArrowUp, BookOpenText, Braces, ChevronDown, + ChevronRight, Code2, + Menu, Search, TerminalSquare, Workflow, + X, } from "lucide-react"; export default function TopicLayout({ @@ -38,6 +45,17 @@ export default function TopicLayout({ Array<{ id: string; title: string; depth: number }> >([]); + // Mobile navigation state + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [isMobileTocOpen, setIsMobileTocOpen] = useState(false); + + // User Engagement State + const [feedbackSubmitted, setFeedbackSubmitted] = useState(false); + + // Scroll to Top state + const [showScrollTop, setShowScrollTop] = useState(false); + + // UX Enhancements: OS detection for the shortcut hint const [isMounted, setIsMounted] = useState(false); const [modifierKey, setModifierKey] = useState("⌘"); @@ -49,6 +67,26 @@ export default function TopicLayout({ } }, []); + useEffect(() => { + if (isMobileMenuOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [isMobileMenuOpen]); + + useEffect(() => { + const handleScroll = () => { + setShowScrollTop(window.scrollY > 400); + }; + + window.addEventListener("scroll", handleScroll, { passive: true }); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + useEffect(() => { const article = document.querySelector("article"); const headingNodes = article @@ -62,44 +100,48 @@ export default function TopicLayout({ })); setDomHeadings(nextHeadings); + + // Reset feedback state when navigating to a new topic + setFeedbackSubmitted(false); }, [children, topicKey]); const activeHeadings = headings.length > 0 ? headings : domHeadings; - const normalizedQuery = docSearch.trim().toLowerCase(); - const hasSearchQuery = normalizedQuery.length > 0; + const hasSearchQuery = docSearch.trim().length > 0; + + // --- FUZZY SEARCH IMPLEMENTATION --- + const searchableData = useMemo(() => { + const sectionDocs = activeHeadings.map((heading) => ({ + id: heading.id, + title: heading.title, + type: "section" as const, + depth: heading.depth, + })); + + const topicDocs = topics.map((item) => ({ + id: item.id, + title: item.title || item.id, + description: item.description || "", + type: "topic" as const, + depth: 1, + })); + + return [...topicDocs, ...sectionDocs]; + }, [activeHeadings, topics]); + + const fuse = useMemo(() => { + return new Fuse(searchableData, { + keys: ["title", "description"], + includeMatches: true, + threshold: 0.3, // 0.0 is exact match, 1.0 is anything. 0.3 is perfect for slight typos + ignoreLocation: true, + }); + }, [searchableData]); const searchResults = useMemo(() => { if (!hasSearchQuery) return []; - - const sectionMatches = activeHeadings - .filter((heading) => - heading.title.toLowerCase().includes(normalizedQuery), - ) - .map((heading) => ({ - id: heading.id, - title: heading.title, - type: "section" as const, - depth: heading.depth, - })); - - const topicMatches = topics - .filter((item) => { - const title = (item.title || item.id || "").toLowerCase(); - const description = (item.description || "").toLowerCase(); - return ( - title.includes(normalizedQuery) || - description.includes(normalizedQuery) - ); - }) - .map((item) => ({ - id: item.id, - title: item.title || item.id, - type: "topic" as const, - depth: 1, - })); - - return [...sectionMatches, ...topicMatches].slice(0, 12); - }, [activeHeadings, hasSearchQuery, normalizedQuery, topics]); + return fuse.search(docSearch.trim()).slice(0, 12); + }, [docSearch, hasSearchQuery, fuse]); + // ----------------------------------- const outlineGroups = useMemo(() => { const groups: Array<{ @@ -203,7 +245,7 @@ export default function TopicLayout({ const theme = { page: darkMode ? "bg-[#050505] text-[#F5F5F5]" : "bg-[#F5F3EE] text-[#111111]", - header: darkMode ? "bg-[#050505]/85" : "bg-[#F5F3EE]/85", + header: darkMode ? "bg-[#050505]/85" : "bg-[#F5F3EE]/85", panel: darkMode ? "bg-[#050505]" : "bg-[#F5F3EE]", border: darkMode ? "border-[#1A1A1A]" : "border-[#DFE1DA]", muted: darkMode ? "text-[#8A8A8A]" : "text-[#666C73]", @@ -228,39 +270,147 @@ export default function TopicLayout({ const scrollToSection = (id: string) => { if (!id) return; - const target = document.getElementById(id); if (!target) return; - const headerOffset = 88; const top = target.getBoundingClientRect().top + window.scrollY - headerOffset; window.scrollTo({ top, behavior: "smooth" }); }; + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + const handleFeedback = (isPositive: boolean) => { + setFeedbackSubmitted(true); + if (isPositive) { + toast.success("Glad it helped! Thanks for the feedback."); + } else { + toast("Thanks for the feedback! We'll work on improving this.", { + icon: '📝', + }); + } + }; + + const currentTopicTitle = topics.find((t) => t.id === topicKey)?.title || frontmatter?.title || topicKey; + return (